thumbgate 1.34.3 → 1.37.1

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 (200) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +373 -5
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +653 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +21 -2
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +10 -1
  34. package/config/model-candidates.json +382 -24
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +401 -147
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/auto-promote-gates.js +178 -27
  89. package/scripts/billing.js +1 -1
  90. package/scripts/broker-execution-receipts.js +719 -0
  91. package/scripts/budget-aware-gates-proof.js +423 -0
  92. package/scripts/claude-feedback-sync.js +29 -3
  93. package/scripts/claw-harness-production.js +237 -0
  94. package/scripts/cli-progress.js +111 -0
  95. package/scripts/cli-schema.js +163 -1
  96. package/scripts/codex-runbook-flywheel.js +318 -0
  97. package/scripts/context-footprint.js +186 -0
  98. package/scripts/contextfs.js +143 -61
  99. package/scripts/dashboard-limits.js +27 -0
  100. package/scripts/dashboard.js +279 -9
  101. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  102. package/scripts/docker-sandbox-planner.js +18 -0
  103. package/scripts/double-blind-eval-protocol.js +252 -0
  104. package/scripts/edotenv-rl-gateway.js +259 -0
  105. package/scripts/ensure-production-search-corpus.js +162 -0
  106. package/scripts/eval-holdout.js +311 -0
  107. package/scripts/feedback-aggregate.js +21 -2
  108. package/scripts/feedback-loop.js +87 -5
  109. package/scripts/feedback-quality.js +9 -0
  110. package/scripts/file-ledger-lock.js +4 -1
  111. package/scripts/financial-control-plane.js +41 -1
  112. package/scripts/find-dormant-requires.js +118 -0
  113. package/scripts/fs-utils.js +84 -8
  114. package/scripts/gate-stats.js +2 -2
  115. package/scripts/gates-engine.js +859 -58
  116. package/scripts/generate-case-study-outreach.js +24 -15
  117. package/scripts/git-at-scale.js +628 -0
  118. package/scripts/governance-conflict-audit.js +1650 -0
  119. package/scripts/governance-difficulty-curriculum.js +328 -0
  120. package/scripts/graphrag-retrieval.js +275 -0
  121. package/scripts/gurobi-optimizer.js +324 -0
  122. package/scripts/gurobi_optimizer.py +485 -0
  123. package/scripts/harness-selector.js +82 -1
  124. package/scripts/hidden-entry-points.js +284 -0
  125. package/scripts/human-escalation.js +199 -1
  126. package/scripts/hybrid-feedback-context.js +152 -19
  127. package/scripts/intent-governed-execution.js +602 -0
  128. package/scripts/intervention-policy.js +123 -20
  129. package/scripts/jit-harness-compose.js +628 -0
  130. package/scripts/jsonl-watcher.js +10 -0
  131. package/scripts/lesson-embedding-index.js +95 -12
  132. package/scripts/lesson-retrieval.js +105 -19
  133. package/scripts/local-model-profile.js +19 -2
  134. package/scripts/mailer/resend-mailer.js +1 -1
  135. package/scripts/matryoshka-embedding.js +235 -0
  136. package/scripts/mcp-oauth.js +42 -4
  137. package/scripts/mcp-session-handles.js +1016 -0
  138. package/scripts/mcp-wiring-doctor.js +314 -0
  139. package/scripts/memory-firewall.js +115 -2
  140. package/scripts/memory-scope-readiness.js +299 -0
  141. package/scripts/memory-vs-rag-route.js +161 -0
  142. package/scripts/model-tier-router.js +148 -21
  143. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  144. package/scripts/openui-catalog-compose-honesty.js +593 -0
  145. package/scripts/operational-integrity.js +19 -1
  146. package/scripts/override-audit.js +213 -0
  147. package/scripts/package-manager-honesty-doctor.js +458 -0
  148. package/scripts/pr-manager.js +63 -1
  149. package/scripts/prove-herdr-adapter.js +52 -0
  150. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  151. package/scripts/prove-workos.js +73 -0
  152. package/scripts/provider-attestation-conformance.js +192 -0
  153. package/scripts/provider-receipt-contract.js +136 -0
  154. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  155. package/scripts/radware-threat-defense.js +280 -0
  156. package/scripts/rag-embedding-identity.js +221 -0
  157. package/scripts/rag-precision-guardrails.js +112 -2
  158. package/scripts/remote-feedback-capture.js +159 -0
  159. package/scripts/research-agent-harness.js +256 -0
  160. package/scripts/rsi-safety-hillclimb.js +200 -0
  161. package/scripts/rule-sprawl.js +188 -0
  162. package/scripts/schedule-manager.js +147 -0
  163. package/scripts/self-heal.js +8 -0
  164. package/scripts/session-lease.js +415 -0
  165. package/scripts/simatree-data-governance.js +347 -0
  166. package/scripts/slo-alert-engine.js +172 -7
  167. package/scripts/solver-parity.js +539 -0
  168. package/scripts/stealth-memory-injection-gate.js +333 -0
  169. package/scripts/switchyard-router.js +366 -0
  170. package/scripts/telemetry-analytics.js +84 -27
  171. package/scripts/temporal-decay-weighting.js +138 -0
  172. package/scripts/test-all.js +165 -0
  173. package/scripts/token-savings.js +42 -0
  174. package/scripts/tool-kpi-tracker.js +108 -5
  175. package/scripts/tool-registry.js +193 -5
  176. package/scripts/universal-claim-evaluator.js +14 -2
  177. package/scripts/vector-store.js +279 -9
  178. package/scripts/workflow-notebook.js +391 -0
  179. package/scripts/workflow-sentinel.js +111 -12
  180. package/scripts/workos-production-guard.js +260 -0
  181. package/scripts/workspace-search-route.js +515 -0
  182. package/server.json +2 -2
  183. package/src/agent-identity-boundary.js +76 -0
  184. package/src/agent-retrieval-cache.js +155 -0
  185. package/src/alert-noise-ledger.js +502 -0
  186. package/src/api/server.js +802 -185
  187. package/src/git-fast-cache.js +220 -0
  188. package/src/git-wal-sync.js +156 -0
  189. package/src/hash-anchored-edit.js +82 -0
  190. package/src/hermes-platform-protocol.js +475 -0
  191. package/src/hermes-sync-plane.js +241 -0
  192. package/src/index.js +30 -1
  193. package/src/iso42001-compliance-guard.js +97 -0
  194. package/src/latency-budget.js +244 -0
  195. package/src/mcp-writeguard.js +316 -0
  196. package/src/miminions-adapter.js +106 -0
  197. package/src/pipeline-compass.js +104 -0
  198. package/src/ppl-alert-pipeline.js +284 -0
  199. package/src/rendezvous-router.js +90 -0
  200. package/src/security-questionnaire.js +195 -0
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * High-Concurrency Agent Retrieval Cache & Anti-Drift Guard.
5
+ *
6
+ * Addresses key failure modes when hundreds of agents hit the retrieval layer:
7
+ * 1. Latency Stacking: Single-flight request coalescing for concurrent duplicate queries.
8
+ * 2. Stale Context: Instantaneous mtime invalidation on file mutation.
9
+ * 3. Relevance Drift / Slop: Automated filtering of transient agent logs, scratch files, and worktree artifacts.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const POLLUTION_PATTERNS = [
16
+ /\.system_generated\//,
17
+ /\.claude\/worktrees\//,
18
+ /\.git\//,
19
+ /node_modules\//,
20
+ /\.tmp\//,
21
+ /\.coverage\//,
22
+ /dist\//,
23
+ /build\//,
24
+ /\.DS_Store/,
25
+ ];
26
+
27
+ class AgentRetrievalCache {
28
+ constructor({ maxEntries = 1000, ttlMs = 300000 } = {}) {
29
+ this.maxEntries = maxEntries;
30
+ this.ttlMs = ttlMs;
31
+ this.cache = new Map(); // key -> { value, timestamp, mtimes: Map<filepath, mtimeMs> }
32
+ this.inFlight = new Map(); // key -> Promise
33
+ this.stats = {
34
+ hits: 0,
35
+ misses: 0,
36
+ coalesced: 0,
37
+ invalidations: 0,
38
+ slopRejections: 0,
39
+ };
40
+ }
41
+
42
+ static isPollutionPath(filepath) {
43
+ if (!filepath || typeof filepath !== 'string') return false;
44
+ const normalized = filepath.replaceAll('\\', '/');
45
+ return POLLUTION_PATTERNS.some((pattern) => pattern.test(normalized));
46
+ }
47
+
48
+ async getOrFetch(key, watchedFiles, fetchFn) {
49
+ const files = Array.isArray(watchedFiles) ? watchedFiles : [];
50
+ const fn = typeof watchedFiles === 'function' ? watchedFiles : fetchFn;
51
+ // 1. Check cache freshness & mtimes
52
+ const entry = this.cache.get(key);
53
+ const now = Date.now();
54
+
55
+ if (entry && (now - entry.timestamp < this.ttlMs)) {
56
+ let isStale = false;
57
+ for (const [file, cachedMtime] of entry.mtimes.entries()) {
58
+ try {
59
+ const stat = fs.statSync(file);
60
+ if (stat.mtimeMs > cachedMtime) {
61
+ isStale = true;
62
+ break;
63
+ }
64
+ } catch {
65
+ isStale = true;
66
+ break;
67
+ }
68
+ }
69
+
70
+ if (!isStale) {
71
+ this.stats.hits++;
72
+ return entry.value;
73
+ }
74
+ this.cache.delete(key);
75
+ this.stats.invalidations++;
76
+ }
77
+
78
+ // 2. Single-flight request coalescing (prevents latency stacking)
79
+ if (this.inFlight.has(key)) {
80
+ this.stats.coalesced++;
81
+ return this.inFlight.get(key);
82
+ }
83
+
84
+ this.stats.misses++;
85
+
86
+ const fetchPromise = (async () => {
87
+ try {
88
+ const result = await fn();
89
+
90
+ // 3. Record mtimes for watched files
91
+ const mtimes = new Map();
92
+ for (const file of files) {
93
+ if (!AgentRetrievalCache.isPollutionPath(file)) {
94
+ try {
95
+ const stat = fs.statSync(file);
96
+ mtimes.set(file, stat.mtimeMs);
97
+ } catch {
98
+ // ignore missing files
99
+ }
100
+ } else {
101
+ this.stats.slopRejections++;
102
+ }
103
+ }
104
+
105
+ if (this.cache.size >= this.maxEntries) {
106
+ const oldestKey = this.cache.keys().next().value;
107
+ this.cache.delete(oldestKey);
108
+ }
109
+
110
+ this.cache.set(key, {
111
+ value: result,
112
+ timestamp: Date.now(),
113
+ mtimes,
114
+ });
115
+
116
+ return result;
117
+ } finally {
118
+ this.inFlight.delete(key);
119
+ }
120
+ })();
121
+
122
+ this.inFlight.set(key, fetchPromise);
123
+ return fetchPromise;
124
+ }
125
+
126
+ invalidateFile(filepath) {
127
+ let purged = 0;
128
+ for (const [key, entry] of this.cache.entries()) {
129
+ if (entry.mtimes.has(filepath)) {
130
+ this.cache.delete(key);
131
+ purged++;
132
+ }
133
+ }
134
+ this.stats.invalidations += purged;
135
+ return purged;
136
+ }
137
+
138
+ clear() {
139
+ this.cache.clear();
140
+ this.inFlight.clear();
141
+ }
142
+
143
+ getMetrics() {
144
+ return {
145
+ ...this.stats,
146
+ size: this.cache.size,
147
+ inFlightCount: this.inFlight.size,
148
+ };
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ AgentRetrievalCache,
154
+ POLLUTION_PATTERNS,
155
+ };
@@ -0,0 +1,502 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Alert noise ledger — suppression and correlation for the reminder surface.
10
+ *
11
+ * WHY THIS EXISTS
12
+ *
13
+ * `scripts/gates-engine.js` injects behavioural context "on EVERY tool call ...
14
+ * even when no gate blocks" (its own comment near line 4118). That is
15
+ * deliberate, but there is no suppression path anywhere on the reminder text:
16
+ * `buildBehavioralContext`, `buildRecentCorrectiveActionsContext` and
17
+ * `formatNegativeLessonContext` are stateless per-call reads, so an identical
18
+ * bullet re-renders in full on every successive call. Measured from live
19
+ * `gate_stats` on 2026-08-25:
20
+ *
21
+ * 706 gate events = 87 blocked + 619 warned
22
+ * 278 first firings + 428 repeats -> 60.6% of all events are repeats
23
+ * retrieval_entropy_high: 558 events, 0 blocks in its entire history
24
+ * force-push: 1 first, 26 repeats (96% repeat rate)
25
+ *
26
+ * and from `prevention_rules` root-cause telemetry:
27
+ *
28
+ * guardrail_triggered: 90 failures <- #1 root-cause category
29
+ * tool_output_misread: 28 failures
30
+ *
31
+ * The guardrails are the leading recorded cause of agent failure. A reminder
32
+ * that has fired 44 times without changing behaviour is not guidance; it is
33
+ * noise displacing the context the agent needs to do the work correctly.
34
+ *
35
+ * WHAT THIS DOES NOT DO
36
+ *
37
+ * Suppression here is PRESENTATIONAL ONLY. It decides how loudly a decision is
38
+ * re-rendered, never what the decision is. Enforcement is untouched: a blocked
39
+ * action stays blocked whether its text renders full, collapsed, or not at all.
40
+ * Two invariants follow, and both are tested:
41
+ *
42
+ * 1. The FIRST occurrence of any signature always renders in full.
43
+ * 2. A `block` is never fully suppressed — it collapses to a one-liner at
44
+ * most, because an agent must always be told its action did not happen.
45
+ *
46
+ * On any internal error the ledger fails OPEN (render everything). Under-
47
+ * rendering a real warning is the dangerous direction; over-rendering is only
48
+ * noisy.
49
+ */
50
+
51
+ /** Repeats 2..COLLAPSE_UNTIL render as a one-line count. Beyond that, warnings go quiet. */
52
+ const COLLAPSE_UNTIL = 3;
53
+
54
+ /** After this many firings of one signature, say so once, then stop repeating. */
55
+ const ESCALATE_AFTER = 5;
56
+
57
+ /** A gate that has warned this many times having never once blocked is demoted. */
58
+ const NEVER_BLOCKED_SAMPLE = 20;
59
+
60
+ /** Matches the existing session bucket in gates-engine.js (SESSION_ACTION_TTL_MS). */
61
+ const SESSION_TTL_MS = 60 * 60 * 1000;
62
+
63
+ /** Cap per collection in the persisted store so one session can never grow it unbounded. */
64
+ const LEDGER_STORE_MAX_ENTRIES = 1024;
65
+
66
+ /**
67
+ * Rule bodies that carry no instruction. These occupy a "High-Priority
68
+ * Contract" slot in prevention-rules output while telling the agent nothing —
69
+ * two were live on 2026-08-25 ("Investigate and prevent recurrence").
70
+ */
71
+ const PLACEHOLDER_RULE =
72
+ /^\s*(investigate and prevent recurrence|prevent recurrence|investigate|tbd|n\/a|none)\s*\.?\s*$/i;
73
+
74
+ /** Headers of the reminder blocks emitted by gates-engine.js. */
75
+ const REMINDER_HEADERS = [
76
+ '[ThumbGate] Past mistakes relevant to this action',
77
+ '[ThumbGate] Recent mistakes (last 24h)',
78
+ '[ThumbGate] Recurring failure patterns',
79
+ '[ThumbGate] Knowledge conflict warning',
80
+ ];
81
+
82
+ /**
83
+ * Collapse the volatile parts of a string so the same underlying operation
84
+ * fingerprints identically across calls.
85
+ *
86
+ * Without this, `cp /tmp/a-1234/x` and `cp /tmp/a-5678/x` look like two
87
+ * different events and neither is ever recognised as a repeat.
88
+ */
89
+ function normalizeAction(action) {
90
+ if (typeof action !== 'string') return '';
91
+ return action
92
+ .toLowerCase()
93
+ .replace(/0x[0-9a-f]+/g, '<hex>')
94
+ .replace(/\b[0-9a-f]{7,40}\b/g, '<sha>')
95
+ .replace(/\b\d{4}-\d{2}-\d{2}t[\d:.]+z?\b/g, '<ts>')
96
+ .replace(/\b\d+\b/g, '<n>')
97
+ .replace(/\s+/g, ' ')
98
+ .trim()
99
+ .slice(0, 200);
100
+ }
101
+
102
+ /**
103
+ * Identity of an alert: which gate, about what, at what severity.
104
+ *
105
+ * Severity is part of the key on purpose. If the same gate escalates from warn
106
+ * to block on the same action, that is genuinely new information and must
107
+ * re-render in full rather than inherit the old signature's suppression.
108
+ */
109
+ function fingerprint(event) {
110
+ const gate = String(event && event.gate ? event.gate : 'unknown');
111
+ const decision = String(event && event.decision ? event.decision : 'warn');
112
+ return `${gate}|${decision}|${normalizeAction(event && event.action)}`;
113
+ }
114
+
115
+ /** True for a rule whose text instructs nothing. */
116
+ function isPlaceholderRule(text) {
117
+ if (typeof text !== 'string' || text.trim() === '') return true;
118
+ return PLACEHOLDER_RULE.test(text);
119
+ }
120
+
121
+ /**
122
+ * Drop no-op rules and exact duplicates, preserving order.
123
+ * Returns what was dropped as well, so a caller can report the reduction
124
+ * instead of silently shrinking its own output.
125
+ */
126
+ function filterRules(rules) {
127
+ const kept = [];
128
+ const dropped = [];
129
+ const seen = new Set();
130
+ for (const rule of Array.isArray(rules) ? rules : []) {
131
+ const text = typeof rule === 'string' ? rule : rule && rule.text;
132
+ if (isPlaceholderRule(text)) {
133
+ dropped.push({ rule, reason: 'placeholder' });
134
+ continue;
135
+ }
136
+ const key = String(text).trim().toLowerCase();
137
+ if (seen.has(key)) {
138
+ dropped.push({ rule, reason: 'duplicate' });
139
+ continue;
140
+ }
141
+ seen.add(key);
142
+ kept.push(rule);
143
+ }
144
+ return { kept, dropped };
145
+ }
146
+
147
+ /** A line that introduces a reminder block rather than carrying content. */
148
+ function isHeaderLine(line) {
149
+ return REMINDER_HEADERS.some((header) => line.startsWith(header));
150
+ }
151
+
152
+ class AlertNoiseLedger {
153
+ /**
154
+ * @param {Object} [options]
155
+ * @param {number} [options.collapseUntil]
156
+ * @param {number} [options.escalateAfter]
157
+ * @param {number} [options.ttlMs]
158
+ * @param {() => number} [options.now] - injectable clock; tests must not sleep.
159
+ * @param {Object} [options.gateHistory] - { [gate]: { blocked, warned } }, e.g. straight
160
+ * from `gate_stats().byGate`. Used only to demote gates that have never blocked.
161
+ */
162
+ constructor(options = {}) {
163
+ this.collapseUntil = options.collapseUntil ?? COLLAPSE_UNTIL;
164
+ this.escalateAfter = options.escalateAfter ?? ESCALATE_AFTER;
165
+ this.ttlMs = options.ttlMs ?? SESSION_TTL_MS;
166
+ this.now = typeof options.now === 'function' ? options.now : () => Date.now();
167
+ this.gateHistory = options.gateHistory || {};
168
+ /** @type {Map<string, number>} signature -> times seen this session */
169
+ this.counts = new Map();
170
+ /** @type {Set<string>} signatures whose escalation notice has been emitted */
171
+ this.escalated = new Set();
172
+ /** @type {Map<string, number>} reminder line -> timestamp last emitted */
173
+ this.lineSeenAt = new Map();
174
+ /**
175
+ * The installed PreToolUse paths launch a NEW Node process per tool call,
176
+ * so in-memory state alone would reset on every invocation and cross-call
177
+ * suppression would never activate. When a storePath is given, state is
178
+ * reloaded from it on construction and re-persisted after each mutation.
179
+ */
180
+ this.storePath = options.storePath || null;
181
+ if (this.storePath) this._load();
182
+ }
183
+
184
+ _load() {
185
+ try {
186
+ const raw = JSON.parse(fs.readFileSync(this.storePath, 'utf8'));
187
+ const now = this.now();
188
+ // A stale store means the session window lapsed: start a fresh ledger.
189
+ if (typeof raw.updatedAt !== 'number' || now - raw.updatedAt > this.ttlMs) return;
190
+ const cutoff = now - this.ttlMs;
191
+ for (const [sig, count] of Array.isArray(raw.counts) ? raw.counts : []) {
192
+ if (typeof sig === 'string' && Number.isFinite(count)) this.counts.set(sig, count);
193
+ }
194
+ for (const sig of Array.isArray(raw.escalated) ? raw.escalated : []) {
195
+ if (typeof sig === 'string') this.escalated.add(sig);
196
+ }
197
+ for (const [key, ts] of Array.isArray(raw.lineSeenAt) ? raw.lineSeenAt : []) {
198
+ if (typeof key === 'string' && Number.isFinite(ts) && ts >= cutoff) this.lineSeenAt.set(key, ts);
199
+ }
200
+ } catch {
201
+ // Fail open: an unreadable store starts a fresh window and renders more,
202
+ // never less.
203
+ }
204
+ }
205
+
206
+ _persist() {
207
+ if (!this.storePath) return;
208
+ try {
209
+ const payload = JSON.stringify({
210
+ updatedAt: this.now(),
211
+ counts: [...this.counts].slice(-LEDGER_STORE_MAX_ENTRIES),
212
+ escalated: [...this.escalated].slice(-LEDGER_STORE_MAX_ENTRIES),
213
+ lineSeenAt: [...this.lineSeenAt].slice(-LEDGER_STORE_MAX_ENTRIES),
214
+ });
215
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
216
+ // Atomic replace: concurrent hook processes in one session last-writer-win,
217
+ // which at worst re-renders one repeat — never a torn/corrupt store.
218
+ const tmpPath = `${this.storePath}.${process.pid}.tmp`;
219
+ fs.writeFileSync(tmpPath, payload);
220
+ fs.renameSync(tmpPath, this.storePath);
221
+ } catch {
222
+ // Fail open: suppression state is a convenience, never worth failing a hook.
223
+ }
224
+ }
225
+
226
+ /**
227
+ * A gate that has warned many times and blocked exactly zero times is not
228
+ * protecting anything; it is narrating. `retrieval_entropy_high` was at
229
+ * 558 warnings / 0 blocks when this was written.
230
+ */
231
+ isNeverBlockingGate(gate) {
232
+ const stats = this.gateHistory[gate];
233
+ if (!stats) return false;
234
+ const blocked = Number(stats.blocked) || 0;
235
+ const warned = Number(stats.warned) || 0;
236
+ return blocked === 0 && warned >= NEVER_BLOCKED_SAMPLE;
237
+ }
238
+
239
+ /**
240
+ * Decide how loudly to render one alert.
241
+ *
242
+ * @param {Object} event - { gate, decision: 'block'|'warn', action, message }
243
+ * @returns {{render: 'full'|'collapsed'|'suppressed', count: number,
244
+ * signature: string, escalate: boolean, reason: string}}
245
+ */
246
+ admit(event) {
247
+ const verdict = this._admitOnce(event);
248
+ this._persist();
249
+ return verdict;
250
+ }
251
+
252
+ _admitOnce(event) {
253
+ try {
254
+ const signature = fingerprint(event);
255
+ const count = (this.counts.get(signature) || 0) + 1;
256
+ this.counts.set(signature, count);
257
+
258
+ const isBlock = String(event && event.decision) === 'block';
259
+
260
+ // Invariant 1: the first sighting always renders in full.
261
+ if (count === 1) {
262
+ return { render: 'full', count, signature, escalate: false, reason: 'first_occurrence' };
263
+ }
264
+
265
+ // Say once, clearly, that this alert is not working — then stop repeating it.
266
+ if (count >= this.escalateAfter && !this.escalated.has(signature)) {
267
+ this.escalated.add(signature);
268
+ return {
269
+ render: 'collapsed',
270
+ count,
271
+ signature,
272
+ escalate: true,
273
+ reason: 'repeated_without_effect',
274
+ };
275
+ }
276
+
277
+ // A gate that never blocks gets one full airing, then stays quiet.
278
+ if (!isBlock && this.isNeverBlockingGate(event && event.gate)) {
279
+ return {
280
+ render: 'suppressed',
281
+ count,
282
+ signature,
283
+ escalate: false,
284
+ reason: 'gate_never_blocks',
285
+ };
286
+ }
287
+
288
+ if (count <= this.collapseUntil) {
289
+ return { render: 'collapsed', count, signature, escalate: false, reason: 'repeat' };
290
+ }
291
+
292
+ // Invariant 2: a block still has to tell the agent it was blocked.
293
+ if (isBlock) {
294
+ return {
295
+ render: 'collapsed',
296
+ count,
297
+ signature,
298
+ escalate: false,
299
+ reason: 'block_always_visible',
300
+ };
301
+ }
302
+
303
+ return {
304
+ render: 'suppressed',
305
+ count,
306
+ signature,
307
+ escalate: false,
308
+ reason: 'repeat_beyond_threshold',
309
+ };
310
+ } catch {
311
+ // Fail open: if anything here misbehaves, show the operator everything.
312
+ return {
313
+ render: 'full',
314
+ count: 1,
315
+ signature: 'error',
316
+ escalate: false,
317
+ reason: 'ledger_error',
318
+ };
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Render one admitted alert.
324
+ * `full` returns the original message untouched — this never rewrites the
325
+ * text of a first-sighting alert.
326
+ */
327
+ format(event, verdict) {
328
+ if (verdict.render === 'suppressed') return null;
329
+ const message = String((event && event.message) || '');
330
+ if (verdict.render === 'full') return message;
331
+
332
+ const gate = String((event && event.gate) || 'gate');
333
+ const line = `[ThumbGate] ${gate} (x${verdict.count}, unchanged - see first occurrence)`;
334
+ if (!verdict.escalate) return line;
335
+ return (
336
+ `${line}\n` +
337
+ ` This alert has now fired ${verdict.count} times without the outcome changing. ` +
338
+ `Either its guidance is not actionable as written, or the gate is miscalibrated ` +
339
+ `for this workload. It will not be repeated again this session.`
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Remove reminder lines already emitted inside the current session window.
345
+ *
346
+ * This is the change that reclaims the bulk of the wasted context: it is the
347
+ * merged reminder block, not the gate decision, that repeats verbatim on
348
+ * every call. A header whose bullets are all suppressed is dropped with them,
349
+ * so no empty section is left behind. Returns null when nothing survives, so
350
+ * the caller can omit `additionalContext` entirely.
351
+ *
352
+ * @param {string} context - merged reminder text
353
+ * @returns {{text: string|null, suppressedLines: number, keptLines: number}}
354
+ */
355
+ suppressRepeatedLines(context) {
356
+ if (typeof context !== 'string' || context === '') {
357
+ return { text: null, suppressedLines: 0, keptLines: 0 };
358
+ }
359
+
360
+ try {
361
+ const now = this.now();
362
+ const cutoff = now - this.ttlMs;
363
+ const blocks = [];
364
+ let current = null;
365
+ let suppressedLines = 0;
366
+ let keptLines = 0;
367
+
368
+ for (const line of context.split('\n')) {
369
+ if (isHeaderLine(line)) {
370
+ current = { header: line, body: [] };
371
+ blocks.push(current);
372
+ continue;
373
+ }
374
+ if (current === null) {
375
+ current = { header: null, body: [] };
376
+ blocks.push(current);
377
+ }
378
+
379
+ // Blank lines carry nothing on their own; they survive only inside a
380
+ // block that still has real content.
381
+ if (line.trim() === '') {
382
+ current.body.push({ line, keep: true, structural: true });
383
+ continue;
384
+ }
385
+
386
+ const key = normalizeAction(line);
387
+ const lastSeen = this.lineSeenAt.get(key);
388
+ const isRepeat = typeof lastSeen === 'number' && lastSeen >= cutoff;
389
+ this.lineSeenAt.set(key, now);
390
+
391
+ if (isRepeat) {
392
+ suppressedLines += 1;
393
+ current.body.push({ line, keep: false, structural: false });
394
+ } else {
395
+ keptLines += 1;
396
+ current.body.push({ line, keep: true, structural: false });
397
+ }
398
+ }
399
+
400
+ const rendered = [];
401
+ for (const block of blocks) {
402
+ const survivors = block.body.filter((entry) => entry.keep && !entry.structural);
403
+ if (survivors.length === 0) continue; // drop the header along with its bullets
404
+ if (block.header) rendered.push(block.header);
405
+ // Structural blank lines ride along with a surviving block so that a
406
+ // first, fully-novel reminder is reproduced byte-for-byte. Suppression
407
+ // must not quietly reformat text it decided to keep.
408
+ for (const entry of block.body) {
409
+ if (entry.keep) rendered.push(entry.line);
410
+ }
411
+ }
412
+ // A separator belonging to the last surviving block has nothing left to
413
+ // separate it from.
414
+ while (rendered.length > 0 && rendered[rendered.length - 1].trim() === '') {
415
+ rendered.pop();
416
+ }
417
+
418
+ this._persist();
419
+ return {
420
+ text: rendered.length > 0 ? rendered.join('\n') : null,
421
+ suppressedLines,
422
+ keptLines,
423
+ };
424
+ } catch {
425
+ // Fail open: on any parsing trouble, hand back exactly what came in.
426
+ return { text: context, suppressedLines: 0, keptLines: 0 };
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Group simultaneous alerts about one action into a single incident.
432
+ *
433
+ * Several gates commonly fire on the same command; rendering three separate
434
+ * walls describes one event three times. Correlating them lets the agent read
435
+ * the action once and see every gate that objected to it.
436
+ */
437
+ correlate(events) {
438
+ const groups = new Map();
439
+ for (const event of Array.isArray(events) ? events : []) {
440
+ const key = normalizeAction(event && event.action);
441
+ if (!groups.has(key)) groups.set(key, { action: key, gates: [], severity: 'warn' });
442
+ const group = groups.get(key);
443
+ group.gates.push(String((event && event.gate) || 'unknown'));
444
+ if (String(event && event.decision) === 'block') group.severity = 'block';
445
+ }
446
+ return [...groups.values()];
447
+ }
448
+
449
+ /** Noise reduction achieved so far, for reporting. */
450
+ stats() {
451
+ let total = 0;
452
+ let unique = 0;
453
+ for (const count of this.counts.values()) {
454
+ total += count;
455
+ unique += 1;
456
+ }
457
+ return {
458
+ totalEvents: total,
459
+ uniqueSignatures: unique,
460
+ repeats: total - unique,
461
+ repeatRatio: total > 0 ? (total - unique) / total : 0,
462
+ };
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Resolve the on-disk store path for one session's ledger. The session id is
468
+ * hashed so arbitrary ids stay filesystem-safe.
469
+ */
470
+ function resolveLedgerStorePath(sessionId, options = {}) {
471
+ const dir = options.storeDir
472
+ || process.env.THUMBGATE_ALERT_LEDGER_DIR
473
+ || path.join(os.tmpdir(), 'thumbgate-alert-ledger');
474
+ const hash = crypto.createHash('sha256').update(String(sessionId || 'default')).digest('hex').slice(0, 16);
475
+ return path.join(dir, `ledger-${hash}.json`);
476
+ }
477
+
478
+ /**
479
+ * Ledger for the run/runAsync integration points: state is keyed by session id
480
+ * and persisted with the session TTL, so suppression, counts and escalation
481
+ * survive the one-process-per-tool-call hook lifecycle.
482
+ */
483
+ function createSessionLedger(sessionId, options = {}) {
484
+ return new AlertNoiseLedger({
485
+ ...options,
486
+ storePath: options.storePath || resolveLedgerStorePath(sessionId, options),
487
+ });
488
+ }
489
+
490
+ module.exports = {
491
+ AlertNoiseLedger,
492
+ createSessionLedger,
493
+ resolveLedgerStorePath,
494
+ fingerprint,
495
+ normalizeAction,
496
+ isPlaceholderRule,
497
+ filterRules,
498
+ COLLAPSE_UNTIL,
499
+ ESCALATE_AFTER,
500
+ NEVER_BLOCKED_SAMPLE,
501
+ SESSION_TTL_MS,
502
+ };