@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
@@ -25,6 +25,8 @@ import type {
25
25
  GateEventInfo,
26
26
  GatePolicyStats,
27
27
  GateStatus,
28
+ ShadowWarning,
29
+ GateProbeRow,
28
30
  } from './types.js';
29
31
 
30
32
  // Re-export types consumers need
@@ -310,11 +312,137 @@ function validatePolicy(raw: unknown): GatePolicy {
310
312
  if (names.length > 0) resets = names;
311
313
  }
312
314
 
315
+ // Validate passthrough — only meaningful on the counting behaviors, where
316
+ // "matched but didn't fire" is a distinct outcome that can fall through.
317
+ // On always/defer/skip/debounce the flag would be a no-op at best and a
318
+ // misleading promise at worst, so reject it loudly instead of ignoring it.
319
+ let passthrough: boolean | undefined;
320
+ if (obj.passthrough !== undefined) {
321
+ if (typeof obj.passthrough !== 'boolean') {
322
+ throw new Error(`Policy "${obj.name}": passthrough must be a boolean`);
323
+ }
324
+ if (obj.passthrough) {
325
+ const counting = typeof behavior === 'object'
326
+ && ('rate_limit' in behavior || 'passive_sample' in behavior);
327
+ if (!counting) {
328
+ throw new Error(
329
+ `Policy "${obj.name}": passthrough is only valid with rate_limit or `
330
+ + `passive_sample behaviors — for "${formatBehavior(behavior)}" there is `
331
+ + 'no non-firing match to fall through on',
332
+ );
333
+ }
334
+ passthrough = true;
335
+ }
336
+ }
337
+
313
338
  const policy: GatePolicy = { name: obj.name, match, behavior };
314
339
  if (resets) policy.resets = resets;
340
+ if (passthrough) policy.passthrough = passthrough;
315
341
  return policy;
316
342
  }
317
343
 
344
+ // ---------------------------------------------------------------------------
345
+ // Shadow lint — prove (conservatively) when rule ordering makes a rule dead
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /** Set-inclusion helper for exact-string lists. */
349
+ function subsetOf(a: string[], b: string[]): boolean {
350
+ return a.every((x) => b.includes(x));
351
+ }
352
+
353
+ /** A glob field on the EARLIER rule is at least as broad as the LATER rule's
354
+ * when it's absent, the universal "*", or literally identical. Anything
355
+ * cleverer (real glob subsumption) is out of conservative scope. */
356
+ function globAtLeastAsBroad(earlier: string | undefined, later: string | undefined): boolean {
357
+ if (earlier === undefined || earlier === '*') return true;
358
+ return earlier === later;
359
+ }
360
+
361
+ /**
362
+ * Event types for which `earlier` provably matches EVERY event that `later`
363
+ * would match — i.e. the types for which `later` is unreachable when
364
+ * `earlier` sits before it and consumes its matches. Returns null when
365
+ * subsumption can't be proven (which is not proof of safety — the lint is
366
+ * deliberately one-sided so every warning it emits is true).
367
+ */
368
+ function subsumedEventTypes(earlier: GatePolicy, later: GatePolicy): string[] | 'all' | null {
369
+ const e = earlier.match;
370
+ const l = later.match;
371
+
372
+ // Scope: which of `later`'s event types does `earlier` cover?
373
+ let covered: string[] | 'all';
374
+ if (!e.scope || e.scope.length === 0) {
375
+ covered = l.scope && l.scope.length > 0 ? l.scope : 'all';
376
+ } else if (!l.scope || l.scope.length === 0) {
377
+ // `later` matches all types; `earlier` only consumes its own scope, so
378
+ // the shadow is partial — exactly those types.
379
+ covered = e.scope;
380
+ } else {
381
+ const overlap = l.scope.filter((t) => e.scope!.includes(t));
382
+ if (overlap.length === 0) return null;
383
+ covered = overlap;
384
+ }
385
+
386
+ // Every remaining discriminator on `earlier` must be at least as broad as
387
+ // `later`'s, otherwise some `later` events slip past `earlier`.
388
+ if (!globAtLeastAsBroad(e.source, l.source)) return null;
389
+ if (!globAtLeastAsBroad(e.channel, l.channel)) return null;
390
+ if (!globAtLeastAsBroad(e.mount, l.mount)) return null;
391
+ if (!globAtLeastAsBroad(e.pathGlob, l.pathGlob)) return null;
392
+
393
+ if (e.filter) {
394
+ if (!l.filter || e.filter.type !== l.filter.type || e.filter.pattern !== l.filter.pattern) {
395
+ return null;
396
+ }
397
+ }
398
+
399
+ // metadataTrue / tagsAny are OR-lists: earlier subsumes when later's list
400
+ // is a subset of earlier's (any event satisfying later then satisfies
401
+ // earlier too). tagsAll / tagsNone are AND/NOT constraints: earlier
402
+ // subsumes when its constraints are a subset of later's.
403
+ if (e.metadataTrue && e.metadataTrue.length > 0) {
404
+ if (!l.metadataTrue || !subsetOf(l.metadataTrue, e.metadataTrue)) return null;
405
+ }
406
+ if (e.tagsAny && e.tagsAny.length > 0) {
407
+ if (!l.tagsAny || !subsetOf(l.tagsAny, e.tagsAny)) return null;
408
+ }
409
+ if (e.tagsAll && !subsetOf(e.tagsAll, l.tagsAll ?? [])) return null;
410
+ if (e.tagsNone && !subsetOf(e.tagsNone, l.tagsNone ?? [])) return null;
411
+
412
+ return covered;
413
+ }
414
+
415
+ /**
416
+ * Find rules made unreachable by earlier, broader rules (first match wins).
417
+ * The 2026-08-08 Mythos incident is the canonical catch: a prepended
418
+ * `{scope:[mcpl:channel-incoming]}` sampler silently made the
419
+ * direct-address and mention rules dead for the whole incoming lane.
420
+ * `passthrough` observers shadow nothing — non-firing matches fall through.
421
+ */
422
+ export function findShadowedPolicies(policies: GatePolicy[]): ShadowWarning[] {
423
+ const out: ShadowWarning[] = [];
424
+ for (let i = 0; i < policies.length; i++) {
425
+ const earlier = policies[i];
426
+ if (earlier.passthrough) continue;
427
+ for (let j = i + 1; j < policies.length; j++) {
428
+ const covered = subsumedEventTypes(earlier, policies[j]);
429
+ if (covered) {
430
+ out.push({ earlier: earlier.name, later: policies[j].name, eventTypes: covered });
431
+ }
432
+ }
433
+ }
434
+ return out;
435
+ }
436
+
437
+ /** Human-readable one-liner for a ShadowWarning, used in tool results,
438
+ * traces, and gate_status alike so the same hazard reads the same way
439
+ * everywhere. */
440
+ export function formatShadowWarning(w: ShadowWarning): string {
441
+ const types = w.eventTypes === 'all' ? 'ALL event types' : w.eventTypes.join(', ');
442
+ return `rule "${w.earlier}" makes "${w.later}" unreachable for ${types} `
443
+ + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`;
444
+ }
445
+
318
446
  // ---------------------------------------------------------------------------
319
447
  // EventGate
320
448
  // ---------------------------------------------------------------------------
@@ -349,6 +477,11 @@ export class EventGate {
349
477
  // Per-policy stats
350
478
  private stats = new Map<string, PolicyStats>();
351
479
 
480
+ // Shadow-lint cache (see refreshShadowWarnings) — recomputed on every
481
+ // config change, fingerprinted so an unchanged hazard set doesn't re-trace.
482
+ private shadowWarningsCache: ShadowWarning[] = [];
483
+ private shadowWarningsFingerprint = '[]';
484
+
352
485
  // Observability counters for events that didn't match any policy
353
486
  private totalEvaluations = 0;
354
487
  private defaultTriggered = 0;
@@ -455,6 +588,7 @@ export class EventGate {
455
588
  // Load config
456
589
  this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
457
590
  this.compiledPolicies = this.compilePolicies(this.config);
591
+ this.refreshShadowWarnings('startup');
458
592
 
459
593
  // Programmable gate: an optional agent-authored `gate.js` next to gate.json.
460
594
  // Absent ⇒ inactive (zero overhead beyond a throttled stat check).
@@ -568,6 +702,7 @@ export class EventGate {
568
702
  this.config = newConfig;
569
703
  this.compiledPolicies = this.compilePolicies(newConfig);
570
704
  this.lastReloadTimestamp = now;
705
+ this.refreshShadowWarnings('reload');
571
706
 
572
707
  this.emitTrace({
573
708
  type: 'gate:config-reloaded',
@@ -598,23 +733,46 @@ export class EventGate {
598
733
  * without waiting for the throttled mtime reload.
599
734
  *
600
735
  * The freshest on-disk config is re-read first, so a concurrent operator edit
601
- * isn't clobbered. A policy with the same `name` is replaced IN PLACE (order
602
- * preserved); otherwise it is appended, or prepended when `position:'prepend'`
603
- * — first match wins, so a wake rule that must beat a broad defer/debounce
604
- * belongs at the front.
736
+ * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no
737
+ * `position` is given (order preserved); WITH a `position` it is moved there
738
+ * — so re-issuing a rule is also how you reposition it. New policies append
739
+ * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING,
740
+ * including addressed-message wake rules — see the shadow lint), and the
741
+ * anchored forms `{before: name}` / `{after: name}` insert relative to an
742
+ * existing rule, which is almost always the safer way to order a sampler
743
+ * or debounce against a specific catch-all.
605
744
  */
606
- addPolicy(rawPolicy: unknown, options?: { position?: 'append' | 'prepend' }): GatePolicy {
745
+ addPolicy(
746
+ rawPolicy: unknown,
747
+ options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } },
748
+ ): GatePolicy {
607
749
  const policy = validatePolicy(rawPolicy);
608
750
  const current = this.loadConfig() ?? this.config;
609
751
  const policies = [...current.policies];
610
752
  const idx = policies.findIndex((p) => p.name === policy.name);
611
- if (idx >= 0) {
612
- policies[idx] = policy;
613
- } else if (options?.position === 'prepend') {
614
- policies.unshift(policy);
753
+ const position = options?.position;
754
+
755
+ if (idx >= 0 && position === undefined) {
756
+ policies[idx] = policy; // replace in place, order preserved
615
757
  } else {
616
- policies.push(policy);
758
+ if (idx >= 0) policies.splice(idx, 1); // repositioning a known rule
759
+ if (position === 'prepend') {
760
+ policies.unshift(policy);
761
+ } else if (position !== undefined && typeof position === 'object') {
762
+ const anchorName = 'before' in position ? position.before : position.after;
763
+ const anchorIdx = policies.findIndex((p) => p.name === anchorName);
764
+ if (anchorIdx < 0) {
765
+ throw new Error(
766
+ `addPolicy: no policy named "${anchorName}" to anchor on — `
767
+ + `have: [${policies.map((p) => p.name).join(', ')}]`,
768
+ );
769
+ }
770
+ policies.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
771
+ } else {
772
+ policies.push(policy);
773
+ }
617
774
  }
775
+
618
776
  this.writeConfig({ ...current, policies });
619
777
  this.emitTrace({
620
778
  type: 'gate:policy-added',
@@ -672,6 +830,7 @@ export class EventGate {
672
830
  writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
673
831
  this.config = config;
674
832
  this.compiledPolicies = this.compilePolicies(config);
833
+ this.refreshShadowWarnings('policy-mutation');
675
834
  this.configSource = 'file';
676
835
  this.configErrors = [];
677
836
  this.lastReloadTimestamp = Date.now();
@@ -683,18 +842,10 @@ export class EventGate {
683
842
  }
684
843
 
685
844
  // =========================================================================
686
- // Policy matching — first match wins
845
+ // Policy matching — first match wins (passthrough observers excepted; the
846
+ // match/decide loop lives inline in evaluate() and probe())
687
847
  // =========================================================================
688
848
 
689
- private matchPolicy(info: GateEventInfo): GatePolicy | null {
690
- for (const compiled of this.compiledPolicies) {
691
- if (this.compiledMatches(compiled, info)) {
692
- return compiled.policy;
693
- }
694
- }
695
- return null;
696
- }
697
-
698
849
  private compiledMatches(compiled: CompiledPolicy, info: GateEventInfo): boolean {
699
850
  const match = compiled.policy.match;
700
851
 
@@ -804,13 +955,32 @@ export class EventGate {
804
955
  // Programmable gate (gate.js) takes precedence when present and returns a
805
956
  // behavior; null/undefined (inactive, error, or a deliberate pass) falls
806
957
  // through to the declarative gate.json policies below.
958
+ //
959
+ // Declarative policies: first match wins — EXCEPT `passthrough` observers,
960
+ // which consume the event only when their counting behavior actually
961
+ // fires. A passthrough policy that matched-but-didn't-fire records its
962
+ // count and evaluation continues, so a density sampler placed early can
963
+ // no longer swallow the addressed-message rules behind it.
807
964
  const scripted = this.gateScript.evaluate(info);
808
- const policy: GatePolicy | null =
809
- scripted != null ? { name: 'gate.js', match: {}, behavior: scripted } : this.matchPolicy(info);
810
- const decision = this.computeDecision(policy, info);
965
+ let decision: GateDecision | null = null;
966
+ const observed: string[] = [];
967
+ if (scripted != null) {
968
+ decision = this.computeDecision({ name: 'gate.js', match: {}, behavior: scripted }, info);
969
+ } else {
970
+ for (const compiled of this.compiledPolicies) {
971
+ if (!this.compiledMatches(compiled, info)) continue;
972
+ const candidate = this.computeDecision(compiled.policy, info);
973
+ if (candidate.trigger || !compiled.policy.passthrough) {
974
+ decision = candidate;
975
+ break;
976
+ }
977
+ observed.push(compiled.policy.name); // counted, didn't fire, fell through
978
+ }
979
+ }
811
980
 
812
981
  this.totalEvaluations++;
813
- if (!policy) {
982
+ if (!decision) {
983
+ decision = this.computeDecision(null, info);
814
984
  const bucket = this.defaultByEventType.get(info.eventType)
815
985
  ?? { triggered: 0, skipped: 0 };
816
986
  if (decision.trigger) {
@@ -822,6 +992,7 @@ export class EventGate {
822
992
  }
823
993
  this.defaultByEventType.set(info.eventType, bucket);
824
994
  }
995
+ if (observed.length > 0) decision.observed = observed;
825
996
 
826
997
  this.emitTrace({
827
998
  type: 'gate:decision',
@@ -831,6 +1002,7 @@ export class EventGate {
831
1002
  matchedPolicy: decision.policyName,
832
1003
  trigger: decision.trigger,
833
1004
  behavior: formatBehavior(decision.behavior),
1005
+ ...(observed.length > 0 ? { observed } : {}),
834
1006
  timestamp: this.now(),
835
1007
  });
836
1008
 
@@ -1002,6 +1174,164 @@ export class EventGate {
1002
1174
  this.passiveSampleFires.delete(name);
1003
1175
  }
1004
1176
 
1177
+ // =========================================================================
1178
+ // Dry-run probes — side-effect-free "what would this event do right now"
1179
+ // =========================================================================
1180
+
1181
+ /**
1182
+ * Non-mutating twin of the behavior decision: would this policy trigger for
1183
+ * this event, given CURRENT counter/bucket state? Reads state, never writes
1184
+ * it — no stats, no traces, no counter increments, no debounce batching.
1185
+ */
1186
+ private wouldTrigger(policy: GatePolicy, info: GateEventInfo): boolean {
1187
+ const b = policy.behavior;
1188
+ if (b === 'always') return true;
1189
+ if (b === 'defer' || b === 'skip') return false;
1190
+ if (typeof b === 'object') {
1191
+ if ('debounce' in b) return false; // batches; never triggers inline
1192
+ if ('rate_limit' in b) {
1193
+ const cfg = b.rate_limit;
1194
+ const key = this.resolveKey(info.metadata, cfg.keyBy);
1195
+ const bucket = this.rateLimitBuckets.get(policy.name)?.get(key);
1196
+ if (!bucket) return true; // fresh bucket starts full
1197
+ let tokens = bucket.tokens;
1198
+ if (tokens < cfg.tokens) {
1199
+ const elapsed = Math.max(0, this.now() - bucket.lastRefill);
1200
+ tokens = Math.min(cfg.tokens, tokens + Math.floor(elapsed / cfg.refillIntervalMs));
1201
+ }
1202
+ return tokens > 0;
1203
+ }
1204
+ if ('passive_sample' in b) {
1205
+ const cfg = b.passive_sample;
1206
+ const key = this.resolveKey(info.metadata, cfg.keyBy);
1207
+ const count = this.passiveSampleCounters.get(policy.name)?.get(key) ?? 0;
1208
+ return count + 1 >= cfg.every;
1209
+ }
1210
+ }
1211
+ return false;
1212
+ }
1213
+
1214
+ /**
1215
+ * Evaluate an event against the declarative policy table WITHOUT side
1216
+ * effects: no counters advance, no debounce batches form, no stats or
1217
+ * traces are recorded, and sleep suppression is ignored (a probe asks
1218
+ * about the table, not the moment). gate.js is not consulted — a script
1219
+ * may be stateful, and a probe must never perturb state.
1220
+ */
1221
+ probe(info: GateEventInfo): GateDecision {
1222
+ for (const compiled of this.compiledPolicies) {
1223
+ if (!this.compiledMatches(compiled, info)) continue;
1224
+ const trigger = this.wouldTrigger(compiled.policy, info);
1225
+ if (trigger || !compiled.policy.passthrough) {
1226
+ return { trigger, policyName: compiled.policy.name, behavior: compiled.policy.behavior };
1227
+ }
1228
+ }
1229
+ const trigger = (this.config.default ?? 'always') === 'always';
1230
+ return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
1231
+ }
1232
+
1233
+ /**
1234
+ * Probe a canonical set of synthetic chat/heartbeat events and report which
1235
+ * rule wins each and whether it would wake. Backs the before/after table in
1236
+ * the wake_add_rule result — the point where a rule change's real semantics
1237
+ * must become legible (af#105; the Mythos stand-back-density incident would
1238
+ * have been visible as `dm: discord-direct-address → stand-back-density`).
1239
+ * Probes are representative shapes, not an exhaustive event space — rules
1240
+ * keyed on specific channels/mounts may not match any probe.
1241
+ */
1242
+ probeTable(): GateProbeRow[] {
1243
+ const chat = (
1244
+ label: string,
1245
+ eventType: string,
1246
+ metadata: Record<string, unknown>,
1247
+ tags: string[],
1248
+ ): { label: string; info: GateEventInfo } => ({
1249
+ label,
1250
+ info: {
1251
+ content: `[probe] ${label}`,
1252
+ eventType,
1253
+ serverId: 'discord',
1254
+ channelId: '',
1255
+ metadata: { ...metadata, eventType },
1256
+ tags,
1257
+ },
1258
+ });
1259
+
1260
+ const probes = [
1261
+ chat('dm (open channel)', 'mcpl:channel-incoming', { isDM: true, isMention: true },
1262
+ ['chat:dm', 'chat:private', 'chat:addressed', 'chat:from-human']),
1263
+ chat('dm (closed channel)', 'mcpl:push-event', { isDM: true, isMention: true },
1264
+ ['chat:dm', 'chat:private', 'chat:addressed', 'chat:from-human']),
1265
+ chat('explicit mention (open channel)', 'mcpl:channel-incoming',
1266
+ { isMention: true, isExplicitMention: true },
1267
+ ['chat:mention', 'chat:addressed', 'chat:from-human']),
1268
+ chat('explicit mention (closed channel)', 'mcpl:push-event',
1269
+ { isMention: true, isExplicitMention: true },
1270
+ ['chat:mention', 'chat:addressed', 'chat:from-human']),
1271
+ chat('reply to you', 'mcpl:channel-incoming', { isMention: true, isReplyToBot: true },
1272
+ ['chat:reply', 'chat:addressed', 'chat:from-human']),
1273
+ chat('ambient (human)', 'mcpl:channel-incoming', {},
1274
+ ['chat:ambient', 'chat:from-human']),
1275
+ chat('ambient (bot)', 'mcpl:channel-incoming', { isBot: true },
1276
+ ['chat:ambient', 'chat:from-bot']),
1277
+ {
1278
+ label: 'heartbeat',
1279
+ info: {
1280
+ content: '[probe] heartbeat',
1281
+ eventType: 'mcpl:push-event',
1282
+ serverId: 'heartbeat',
1283
+ channelId: '',
1284
+ metadata: { source: 'heartbeat', eventType: 'mcpl:push-event' },
1285
+ } as GateEventInfo,
1286
+ },
1287
+ ];
1288
+
1289
+ return probes.map(({ label, info }) => {
1290
+ const d = this.probe(info);
1291
+ return {
1292
+ probe: label,
1293
+ eventType: info.eventType,
1294
+ policy: d.policyName,
1295
+ behavior: d.policyName === null
1296
+ ? `default:${formatBehavior(d.behavior)}`
1297
+ : formatBehavior(d.behavior),
1298
+ wouldWake: d.trigger,
1299
+ };
1300
+ });
1301
+ }
1302
+
1303
+ // =========================================================================
1304
+ // Shadow lint surface
1305
+ // =========================================================================
1306
+
1307
+ /** Current shadow-lint findings for the live policy order. Fresh compute —
1308
+ * cheap (policy lists are tens of entries at most). */
1309
+ lintShadows(): ShadowWarning[] {
1310
+ return findShadowedPolicies(this.config.policies);
1311
+ }
1312
+
1313
+ /**
1314
+ * Recompute shadow warnings and, when the set changed and is non-empty,
1315
+ * emit a `gate:shadow-warnings` trace so the hazard lands in ops
1316
+ * observability at the moment the order changed — not at the next missed
1317
+ * DM. Called from writeConfig and reloadIfChanged.
1318
+ */
1319
+ private refreshShadowWarnings(reason: string): void {
1320
+ const warnings = this.lintShadows();
1321
+ const fingerprint = JSON.stringify(warnings);
1322
+ if (fingerprint === this.shadowWarningsFingerprint) return;
1323
+ this.shadowWarningsFingerprint = fingerprint;
1324
+ this.shadowWarningsCache = warnings;
1325
+ if (warnings.length > 0) {
1326
+ this.emitTrace({
1327
+ type: 'gate:shadow-warnings',
1328
+ reason,
1329
+ warnings: warnings.map(formatShadowWarning),
1330
+ timestamp: this.now(),
1331
+ });
1332
+ }
1333
+ }
1334
+
1005
1335
  // =========================================================================
1006
1336
  // shouldTriggerInference callback adapter
1007
1337
  // =========================================================================
@@ -1436,6 +1766,7 @@ export class EventGate {
1436
1766
  script: this.gateScript.status(),
1437
1767
  policies,
1438
1768
  errors: [...this.configErrors],
1769
+ shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning),
1439
1770
  totalEvaluations: this.totalEvaluations,
1440
1771
  defaultDecisions: {
1441
1772
  triggered: this.defaultTriggered,
package/src/gate/types.ts CHANGED
@@ -118,6 +118,22 @@ export interface GatePolicy {
118
118
  * coupling the behaviors to each other in code. Unknown names are ignored.
119
119
  */
120
120
  resets?: string[];
121
+ /**
122
+ * Observer semantics for the counting behaviors (`passive_sample`,
123
+ * `rate_limit`): when the behavior does NOT fire for a matching event
124
+ * (sampler below N, bucket empty), evaluation CONTINUES to later policies
125
+ * instead of consuming the event with trigger=false. When it DOES fire,
126
+ * it triggers as usual and evaluation stops.
127
+ *
128
+ * This is how to express "additionally wake me every Nth event" without
129
+ * silently swallowing the events in between — a plain (consuming)
130
+ * `passive_sample` placed early makes every later rule unreachable for
131
+ * its scope, which is almost never what a density governor intends
132
+ * (2026-08-08 Mythos stand-back-density incident). Validation rejects
133
+ * `passthrough` on `always`/`defer`/`skip`/`debounce`, where "didn't
134
+ * fire" has no meaning distinct from the behavior itself.
135
+ */
136
+ passthrough?: boolean;
121
137
  }
122
138
 
123
139
  // ---------------------------------------------------------------------------
@@ -162,6 +178,42 @@ export interface GateDecision {
162
178
  policyName: string | null;
163
179
  /** The behavior that was applied. */
164
180
  behavior: GateBehavior;
181
+ /**
182
+ * Names of `passthrough` policies that matched and counted this event but
183
+ * did not fire, so evaluation continued past them. Absent when none did.
184
+ */
185
+ observed?: string[];
186
+ }
187
+
188
+ /**
189
+ * A rule-ordering hazard: `earlier` matches (and consumes) every event that
190
+ * `later` would match, for the listed event types — so `later` can never
191
+ * fire for them. Produced by the conservative shadow lint; a warning here is
192
+ * always a true statement about the config, but a config with no warnings is
193
+ * not guaranteed hazard-free (the lint only proves what it can prove).
194
+ */
195
+ export interface ShadowWarning {
196
+ /** Name of the earlier (consuming) policy. */
197
+ earlier: string;
198
+ /** Name of the later policy it makes unreachable. */
199
+ later: string;
200
+ /** Event types affected — 'all' when both rules are unscoped. */
201
+ eventTypes: string[] | 'all';
202
+ }
203
+
204
+ /** One row of the dry-run probe table (see EventGate.probeTable). */
205
+ export interface GateProbeRow {
206
+ /** Short label for the synthetic event probed (e.g. "dm", "mention"). */
207
+ probe: string;
208
+ /** Event type the probe used. */
209
+ eventType: string;
210
+ /** Winning policy name, or null when the default applied. */
211
+ policy: string | null;
212
+ /** Compact behavior description (formatBehavior output or the default). */
213
+ behavior: string;
214
+ /** Whether this event WOULD trigger inference right now. For counting
215
+ * behaviors this reflects current counter/bucket state. */
216
+ wouldWake: boolean;
165
217
  }
166
218
 
167
219
  /** Information about an event being evaluated. */
@@ -211,6 +263,12 @@ export interface GateStatus {
211
263
  script: import('./gate-script.js').GateScriptStatus;
212
264
  policies: GatePolicyStats[];
213
265
  errors: string[];
266
+ /**
267
+ * Active shadow-lint findings for the current policy order (see
268
+ * ShadowWarning). Rendered so an agent inspecting gate_status sees the
269
+ * same hazards wake_add_rule warns about at install time.
270
+ */
271
+ shadowWarnings: string[];
214
272
  /** Total events the gate has evaluated since startup. */
215
273
  totalEvaluations: number;
216
274
  /**
@@ -38,3 +38,24 @@ export function parseProsePrefix(text: string): ProsePrefix {
38
38
  }
39
39
  return { kind: 'target', target, continueTurn, body };
40
40
  }
41
+
42
+ /** A real hybrid routing line: exact triple-arrow plus an optional whitespace
43
+ * run and a non-whitespace target token. */
44
+ export const HYBRID_PROSE_PREFIX_LINE = /^>>>[ \t]*\S/;
45
+
46
+ /**
47
+ * Hybrid/locus-preserving routing grammar.
48
+ *
49
+ * `>>>#cafe body` and `>>> #cafe body` route this envelope explicitly while
50
+ * unprefixed prose remains ordinary frozen-locus speech. The authored source
51
+ * is never rewritten; callers consume this view only for publication.
52
+ */
53
+ export function parseHybridProsePrefix(text: string): ProsePrefix {
54
+ const m = /^>>>[ \t]*(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
55
+ if (!m) return { kind: 'none', continueTurn: false, body: text };
56
+ const target = m[1]!;
57
+ const continueTurn = m[2] !== undefined;
58
+ const body = text.slice(m[0].length);
59
+ if (target === 'skip_reply') return { kind: 'private', continueTurn, body };
60
+ return { kind: 'target', target, continueTurn, body };
61
+ }