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,869 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * agent-action-inventory.js — "what did the agents actually do here?"
6
+ *
7
+ * WHY THIS EXISTS
8
+ * ---------------
9
+ * ThumbGate already records gate decisions (`audit-trail.jsonl`), gate firings
10
+ * (`gate-events-log.jsonl`) and per-tool KPIs (`tool-kpi.jsonl`). What it did
11
+ * not have is one read-only rollup an operator can point at a repo and get:
12
+ * which agents ran, which tools they reached for, how much was allowed, how
13
+ * much was blocked, by which gate, on which day — and how often a block was
14
+ * later reversed (the false-deny signal).
15
+ *
16
+ * TWO HONESTY RULES THIS FILE ENFORCES
17
+ * ------------------------------------
18
+ * 1. A missing source and an empty source are DIFFERENT facts and must look
19
+ * different in the output. `sources.auditTrail === 'missing'` means the file
20
+ * is not there; `'empty'` means it is there and holds nothing. Reporting
21
+ * both as `0` would let "we never instrumented this" masquerade as "the
22
+ * agents did nothing".
23
+ * 2. `falseDenyRate` is null unless a real numerator AND a real denominator
24
+ * were observed. A rate over zero denies is an invented denominator, so it
25
+ * stays null and `falseDenyReason` says why. `falseDenyNumerator` and
26
+ * `falseDenyDenominator` are always present so a reader can redo the math.
27
+ *
28
+ * All functions here are read-only. Nothing in this module writes to the store.
29
+ */
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ // Filenames come from the modules that own the writes, so a rename there can
35
+ // never silently turn this report into a "source missing" false negative.
36
+ const { AUDIT_LOG_FILENAME, GATE_EVENTS_LOG_FILENAME } = require('./audit-trail');
37
+
38
+ // scripts/tool-kpi-tracker.js writes this; it is the only store in the feedback
39
+ // dir that carries an agentId, which is why agent identity comes from here.
40
+ const KPI_LOG_FILENAME = 'tool-kpi.jsonl';
41
+
42
+ const MIN_WINDOW_DAYS = 1;
43
+ const MAX_WINDOW_DAYS = 365;
44
+ const DEFAULT_WINDOW_DAYS = 30;
45
+
46
+ const DENY_DECISION = 'deny';
47
+
48
+ /**
49
+ * WHAT COUNTS AS A REVERSAL — and the trap that makes this subtle.
50
+ *
51
+ * Only two record shapes in audit-trail.jsonl mean "a block was actually cleared":
52
+ *
53
+ * 1. decision === 'override'
54
+ * Written by scripts/override-audit.js for satisfy_gate, the CLI
55
+ * satisfyCondition path, and break-glass. Always carries the cleared gateId.
56
+ *
57
+ * 2. decision === 'allow' AND source === 'gates-engine-admin-override'
58
+ * Written by gates-engine.js when a single-use admin override is CONSUMED
59
+ * against a blocking gate. The decision field reads 'allow' — the reversal
60
+ * is identifiable only by the source.
61
+ *
62
+ * `decision: 'approve'` is NOT a reversal, despite the name. gates-engine.js
63
+ * emits it when a gate's action is 'approve' and the action needs human
64
+ * sign-off: it ships alongside `requiresApproval: true`, formatOutput renders it
65
+ * to the harness as `permissionDecision: 'deny'` with "APPROVAL REQUIRED",
66
+ * isBlockingDecision() groups it with 'deny', and recordStat files it under
67
+ * `pendingApproval`. It is a HELD action — another kind of block.
68
+ *
69
+ * Counting 'approve' as a clearance inflates the false-deny numerator with
70
+ * records that are themselves blocks. It is reported separately as
71
+ * `approvalRequiredCount` and never enters the numerator.
72
+ */
73
+ const CLEARING_DECISION = 'override';
74
+ const ADMIN_OVERRIDE_SOURCE = 'gates-engine-admin-override';
75
+ const APPROVAL_REQUIRED_DECISION = 'approve';
76
+
77
+ /** True only for records that genuinely reversed a block. */
78
+ function isClearingRecord(record) {
79
+ if (!record) return false;
80
+ if (record.decision === CLEARING_DECISION) return true;
81
+ return record.decision === 'allow' && record.source === ADMIN_OVERRIDE_SOURCE;
82
+ }
83
+
84
+ const SOURCE_STATUS = Object.freeze({
85
+ OK: 'ok',
86
+ EMPTY: 'empty',
87
+ MISSING: 'missing',
88
+ UNREADABLE: 'unreadable',
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Input normalization
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** Mirrors scripts/session-report.js normalizeWindowHours: clamp, never throw. */
96
+ function normalizeWindowDays(input) {
97
+ if (input === null || input === undefined || input === '') return DEFAULT_WINDOW_DAYS;
98
+ const n = Number(input);
99
+ if (!Number.isFinite(n)) return DEFAULT_WINDOW_DAYS;
100
+ if (n < MIN_WINDOW_DAYS) return MIN_WINDOW_DAYS;
101
+ if (n > MAX_WINDOW_DAYS) return MAX_WINDOW_DAYS;
102
+ return Math.floor(n);
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Source reading
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Default tail window per source. Matches scripts/override-audit.js, which
111
+ * reads the tail rather than the whole file because audit logs reach hundreds
112
+ * of MB and a report must not allocate the entire history to summarise it.
113
+ */
114
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
115
+
116
+ /**
117
+ * Read one JSONL store and report WHY it produced the records it did.
118
+ *
119
+ * Reads at most `maxBytes` from the END of the file, following the
120
+ * scripts/override-audit.js convention: allocate `Math.min(size, maxBytes)`,
121
+ * seek to `size - readBytes`, then discard the partial first line, since a
122
+ * byte-offset read almost always lands mid-record.
123
+ *
124
+ * When the file was larger than the window, `truncated` is true and
125
+ * `records`/`lines` describe only the tail that was read. Callers MUST surface
126
+ * that — a truncated `recordsTotal` presented as a whole-file count is exactly
127
+ * the kind of silently-partial number this module exists to avoid.
128
+ *
129
+ * @returns {{ status: string, path: string, records: object[], lines: number,
130
+ * malformed: number, truncated: boolean, bytesRead: number, fileBytes: number }}
131
+ */
132
+ function readJsonlSource(filePath, options = {}) {
133
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
134
+ ? options.maxBytes
135
+ : DEFAULT_MAX_BYTES;
136
+ const base = {
137
+ path: filePath,
138
+ records: [],
139
+ lines: 0,
140
+ malformed: 0,
141
+ truncated: false,
142
+ bytesRead: 0,
143
+ fileBytes: 0,
144
+ };
145
+
146
+ let size;
147
+ try {
148
+ ({ size } = fs.statSync(filePath));
149
+ } catch (err) {
150
+ // ENOENT is "never written". Anything else (EACCES, EISDIR) is a real
151
+ // read failure and must not be flattened into "missing" — an operator
152
+ // fixes those two problems differently.
153
+ if (err && err.code === 'ENOENT') return { ...base, status: SOURCE_STATUS.MISSING };
154
+ return {
155
+ ...base,
156
+ status: SOURCE_STATUS.UNREADABLE,
157
+ error: String(err && err.message ? err.message : err),
158
+ };
159
+ }
160
+
161
+ if (size === 0) return { ...base, status: SOURCE_STATUS.EMPTY };
162
+
163
+ const readBytes = Math.min(size, maxBytes);
164
+ const truncated = readBytes < size;
165
+ let text = '';
166
+ let fd;
167
+ try {
168
+ const buf = Buffer.alloc(readBytes);
169
+ fd = fs.openSync(filePath, 'r');
170
+ fs.readSync(fd, buf, 0, readBytes, size - readBytes);
171
+ text = buf.toString('utf8');
172
+ if (truncated) {
173
+ // Drop the partial first record left by the byte-offset seek.
174
+ const nl = text.indexOf('\n');
175
+ text = nl === -1 ? '' : text.slice(nl + 1);
176
+ }
177
+ } catch (err) {
178
+ return {
179
+ ...base,
180
+ fileBytes: size,
181
+ status: SOURCE_STATUS.UNREADABLE,
182
+ error: String(err && err.message ? err.message : err),
183
+ };
184
+ } finally {
185
+ if (fd !== undefined) {
186
+ try { fs.closeSync(fd); } catch { /* already closed */ }
187
+ }
188
+ }
189
+
190
+ const trimmed = text.trim();
191
+ if (!trimmed) {
192
+ return { ...base, status: SOURCE_STATUS.EMPTY, truncated, bytesRead: readBytes, fileBytes: size };
193
+ }
194
+
195
+ const lines = trimmed.split('\n');
196
+ const records = [];
197
+ let malformed = 0;
198
+ for (const line of lines) {
199
+ if (!line.trim()) continue;
200
+ try {
201
+ const parsed = JSON.parse(line);
202
+ if (parsed && typeof parsed === 'object') records.push(parsed);
203
+ else malformed += 1;
204
+ } catch {
205
+ malformed += 1; // a corrupt line must not abort the scan
206
+ }
207
+ }
208
+
209
+ return {
210
+ path: filePath,
211
+ // A file of nothing but unparsable lines is empty of DATA, not missing.
212
+ status: records.length > 0 ? SOURCE_STATUS.OK : SOURCE_STATUS.EMPTY,
213
+ records,
214
+ lines: lines.length,
215
+ malformed,
216
+ truncated,
217
+ bytesRead: readBytes,
218
+ fileBytes: size,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Did the tail window actually cover the whole requested window?
224
+ *
225
+ * If the file was truncated AND its oldest surviving record is newer than
226
+ * `since`, then records inside the window were dropped and every count derived
227
+ * from this source is a partial view. That must be reported, not assumed away.
228
+ */
229
+ function coversWindow(source, sinceMs) {
230
+ if (source.status !== SOURCE_STATUS.OK) return null;
231
+ if (!source.truncated) return true;
232
+ let oldest = null;
233
+ for (const r of source.records) {
234
+ const ts = timestampMs(r);
235
+ if (ts === null) continue;
236
+ if (oldest === null || ts < oldest) oldest = ts;
237
+ }
238
+ if (oldest === null) return false;
239
+ return oldest <= sinceMs;
240
+ }
241
+
242
+ function timestampMs(record, field = 'timestamp') {
243
+ const value = record && record[field];
244
+ if (!value) return null;
245
+ const ms = Date.parse(value);
246
+ return Number.isFinite(ms) ? ms : null;
247
+ }
248
+
249
+ function withinWindow(record, sinceMs, field = 'timestamp') {
250
+ const ms = timestampMs(record, field);
251
+ if (ms === null) return false;
252
+ return ms >= sinceMs;
253
+ }
254
+
255
+ function dayKey(record, field = 'timestamp') {
256
+ const ms = timestampMs(record, field);
257
+ if (ms === null) return null;
258
+ return new Date(ms).toISOString().slice(0, 10);
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // False-deny pairing
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Pair each clearing event (override/approve) with the most recent UNPAIRED
267
+ * preceding deny of the same gate.
268
+ *
269
+ * Why 1:1 and not "any later clear marks every earlier deny of that gate":
270
+ * one break-glass override would then retroactively brand hundreds of
271
+ * force-push denies as false, which is a fabricated numerator. One receipt
272
+ * clears at most one block. The pairing is therefore a LOWER BOUND on
273
+ * reversals, and the method string in the output says so.
274
+ *
275
+ * @returns {{ numerator: number, denominator: number, clearEvents: number, pairs: object[] }}
276
+ */
277
+ function pairDeniesWithClears(records) {
278
+ const denyStack = new Map(); // gateId -> ascending [{ ts, record }]
279
+ const clearEvents = [];
280
+ let denominator = 0;
281
+
282
+ const sorted = records
283
+ .map((r) => ({ r, ts: timestampMs(r) }))
284
+ .filter((x) => x.ts !== null)
285
+ .sort((a, b) => a.ts - b.ts);
286
+
287
+ for (const { r, ts } of sorted) {
288
+ const gateId = r.gateId || null;
289
+ if (r.decision === DENY_DECISION) {
290
+ denominator += 1;
291
+ if (!gateId) continue; // an unattributed deny can never be paired
292
+ if (!denyStack.has(gateId)) denyStack.set(gateId, []);
293
+ denyStack.get(gateId).push({ ts, record: r });
294
+ } else if (isClearingRecord(r) && gateId) {
295
+ clearEvents.push({ ts, gateId, decision: r.decision, source: r.source || null });
296
+ }
297
+ }
298
+
299
+ const pairs = [];
300
+ for (const { ts, gateId, decision } of clearEvents) {
301
+ const stack = denyStack.get(gateId);
302
+ if (!stack || stack.length === 0) continue;
303
+ // Newest unpaired deny that happened BEFORE this clear.
304
+ let idx = -1;
305
+ for (let i = stack.length - 1; i >= 0; i--) {
306
+ if (stack[i].ts < ts) { idx = i; break; }
307
+ }
308
+ if (idx === -1) continue;
309
+ const [matched] = stack.splice(idx, 1);
310
+ pairs.push({
311
+ gate: gateId,
312
+ deniedAt: new Date(matched.ts).toISOString(),
313
+ clearedAt: new Date(ts).toISOString(),
314
+ clearedBy: decision,
315
+ });
316
+ }
317
+
318
+ return { numerator: pairs.length, denominator, clearEvents: clearEvents.length, pairs };
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Aggregation helpers
323
+ // ---------------------------------------------------------------------------
324
+
325
+ function buildToolCalls(records) {
326
+ const byTool = {};
327
+ for (const r of records) {
328
+ const tool = (typeof r.toolName === 'string' && r.toolName) || 'unknown';
329
+ if (!byTool[tool]) {
330
+ byTool[tool] = { total: 0, allow: 0, deny: 0, warn: 0, approvalRequired: 0, other: 0 };
331
+ }
332
+ const bucket = byTool[tool];
333
+ bucket.total += 1;
334
+ if (r.decision === APPROVAL_REQUIRED_DECISION) bucket.approvalRequired += 1;
335
+ else if (r.decision === DENY_DECISION) bucket.deny += 1;
336
+ else if (r.decision === 'warn') bucket.warn += 1;
337
+ else if (r.decision === 'allow') bucket.allow += 1;
338
+ else bucket.other += 1;
339
+ }
340
+ return byTool;
341
+ }
342
+
343
+ function buildDenyReasonsByGate(records) {
344
+ const byGate = {};
345
+ for (const r of records) {
346
+ if (r.decision !== DENY_DECISION) continue;
347
+ const gate = r.gateId || '(unattributed)';
348
+ if (!byGate[gate]) byGate[gate] = { denies: 0, reasons: {} };
349
+ byGate[gate].denies += 1;
350
+ const reason = (typeof r.message === 'string' && r.message.trim())
351
+ ? r.message.trim()
352
+ : '(no message recorded)';
353
+ byGate[gate].reasons[reason] = (byGate[gate].reasons[reason] || 0) + 1;
354
+ }
355
+
356
+ // Reasons as a sorted array so the shape is stable and rankable.
357
+ for (const gate of Object.keys(byGate)) {
358
+ byGate[gate].reasons = Object.entries(byGate[gate].reasons)
359
+ .map(([message, count]) => ({ message, count }))
360
+ .sort((a, b) => b.count - a.count || a.message.localeCompare(b.message));
361
+ }
362
+ return byGate;
363
+ }
364
+
365
+ /** Mirrors scripts/session-report.js topGates: rank by blocks, then warns. */
366
+ function buildTopGates(records, limit = 5) {
367
+ const byGate = {};
368
+ for (const r of records) {
369
+ if (!r.gateId) continue;
370
+ if (!byGate[r.gateId]) {
371
+ byGate[r.gateId] = {
372
+ gate: r.gateId,
373
+ denies: 0,
374
+ warns: 0,
375
+ approvalRequired: 0,
376
+ overrides: 0,
377
+ other: 0,
378
+ };
379
+ }
380
+ const bucket = byGate[r.gateId];
381
+ if (r.decision === DENY_DECISION) bucket.denies += 1;
382
+ else if (r.decision === 'warn') bucket.warns += 1;
383
+ else if (r.decision === APPROVAL_REQUIRED_DECISION) bucket.approvalRequired += 1;
384
+ else if (isClearingRecord(r)) bucket.overrides += 1;
385
+ else bucket.other += 1;
386
+ }
387
+ return Object.values(byGate)
388
+ .sort((a, b) => b.denies - a.denies || b.warns - a.warns || a.gate.localeCompare(b.gate))
389
+ .slice(0, limit);
390
+ }
391
+
392
+ /**
393
+ * Per-day calls/denies. Only days that ACTUALLY APPEAR in the log are listed —
394
+ * a zero-filled calendar would print "0 calls" for days the store may simply
395
+ * predate, which is exactly the measured-vs-absent confusion this report exists
396
+ * to avoid. `undated` counts rows whose timestamp could not be parsed.
397
+ */
398
+ function buildDaily(records) {
399
+ const byDay = new Map();
400
+ let undated = 0;
401
+ for (const r of records) {
402
+ const day = dayKey(r);
403
+ if (!day) { undated += 1; continue; }
404
+ if (!byDay.has(day)) byDay.set(day, { date: day, calls: 0, denies: 0, warns: 0 });
405
+ const bucket = byDay.get(day);
406
+ bucket.calls += 1;
407
+ if (r.decision === DENY_DECISION) bucket.denies += 1;
408
+ else if (r.decision === 'warn') bucket.warns += 1;
409
+ }
410
+ const daily = [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
411
+ return { daily, undated };
412
+ }
413
+
414
+ /**
415
+ * Agents come from tool-kpi.jsonl — the ONLY store here that records an agentId.
416
+ * audit-trail.jsonl rows carry no agent identity, which is why gate decisions
417
+ * are reported repo-wide and never split per agent.
418
+ */
419
+ function buildAgents(kpiRecords) {
420
+ const byAgent = new Map();
421
+ for (const r of kpiRecords) {
422
+ const agentId = (typeof r.agentId === 'string' && r.agentId.trim())
423
+ ? r.agentId.trim()
424
+ : '(no agentId recorded)';
425
+ if (!byAgent.has(agentId)) {
426
+ byAgent.set(agentId, {
427
+ agentId,
428
+ calls: 0,
429
+ successes: 0,
430
+ failures: 0,
431
+ tools: {},
432
+ firstSeen: null,
433
+ lastSeen: null,
434
+ });
435
+ }
436
+ const bucket = byAgent.get(agentId);
437
+ bucket.calls += 1;
438
+ if (r.success === true) bucket.successes += 1;
439
+ else if (r.success === false) bucket.failures += 1;
440
+ const tool = (typeof r.toolName === 'string' && r.toolName) || 'unknown';
441
+ bucket.tools[tool] = (bucket.tools[tool] || 0) + 1;
442
+ const ts = timestampMs(r);
443
+ if (ts !== null) {
444
+ const iso = new Date(ts).toISOString();
445
+ if (!bucket.firstSeen || iso < bucket.firstSeen) bucket.firstSeen = iso;
446
+ if (!bucket.lastSeen || iso > bucket.lastSeen) bucket.lastSeen = iso;
447
+ }
448
+ }
449
+
450
+ return [...byAgent.values()]
451
+ .map((a) => ({
452
+ ...a,
453
+ tools: Object.entries(a.tools)
454
+ .map(([tool, calls]) => ({ tool, calls }))
455
+ .sort((x, y) => y.calls - x.calls || x.tool.localeCompare(y.tool)),
456
+ }))
457
+ .sort((a, b) => b.calls - a.calls || a.agentId.localeCompare(b.agentId));
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Report assembly
462
+ // ---------------------------------------------------------------------------
463
+
464
+ function sourceDetail(source, inWindow, sinceMs) {
465
+ const detail = {
466
+ status: source.status,
467
+ path: source.path,
468
+ // NOTE: when `truncated` is true this counts only the records inside the
469
+ // tail window that was read, never the whole file.
470
+ recordsTotal: source.records.length,
471
+ recordsInWindow: source.status === SOURCE_STATUS.OK ? inWindow : null,
472
+ malformedLines: source.malformed,
473
+ truncated: Boolean(source.truncated),
474
+ bytesRead: source.bytesRead || 0,
475
+ fileBytes: source.fileBytes || 0,
476
+ coversWindow: coversWindow(source, sinceMs),
477
+ };
478
+ if (source.error) detail.error = source.error;
479
+ return detail;
480
+ }
481
+
482
+ function buildAgentAttribution(kpiSource, agents) {
483
+ const base = {
484
+ identitySource: KPI_LOG_FILENAME,
485
+ // Stated explicitly because it is the single biggest limitation of this
486
+ // report: allow/deny counts are repo-wide, NOT per-agent.
487
+ gateDecisionsPerAgent: 'unavailable',
488
+ reason: `${AUDIT_LOG_FILENAME} records carry no agentId, so allow/deny/warn counts cannot be split per agent. Agent rows come from ${KPI_LOG_FILENAME} and cover tool calls only.`,
489
+ };
490
+
491
+ if (kpiSource.status === SOURCE_STATUS.MISSING) {
492
+ return {
493
+ ...base,
494
+ status: 'missing-source',
495
+ reason: `${KPI_LOG_FILENAME} is not present at ${kpiSource.path}; no agent identity is recorded anywhere in this store.`,
496
+ };
497
+ }
498
+ if (kpiSource.status === SOURCE_STATUS.UNREADABLE) {
499
+ return {
500
+ ...base,
501
+ status: 'unreadable-source',
502
+ reason: `${KPI_LOG_FILENAME} could not be read: ${kpiSource.error}`,
503
+ };
504
+ }
505
+ if (kpiSource.status === SOURCE_STATUS.EMPTY) {
506
+ return {
507
+ ...base,
508
+ status: 'empty-source',
509
+ reason: `${KPI_LOG_FILENAME} exists at ${kpiSource.path} but holds no parsable records.`,
510
+ };
511
+ }
512
+ if (agents.length === 0) {
513
+ return { ...base, status: 'no-agents-in-window' };
514
+ }
515
+
516
+ const unknown = agents.find(
517
+ (a) => a.agentId === 'unknown' || a.agentId === '(no agentId recorded)'
518
+ );
519
+ return {
520
+ ...base,
521
+ status: 'partial',
522
+ distinctAgents: agents.length,
523
+ unattributedCalls: unknown ? unknown.calls : 0,
524
+ };
525
+ }
526
+
527
+ /**
528
+ * falseDenyRate = paired reversals / denies observed.
529
+ *
530
+ * Returns null (with a reason) whenever a real denominator was not observed.
531
+ * The numerator and denominator are ALWAYS returned raw so the caller can see
532
+ * exactly what the rate was — or was not — computed from.
533
+ */
534
+ function computeFalseDeny(auditSource, auditInWindow, windowDays) {
535
+ const unmeasurable = (reason) => ({
536
+ falseDenyRate: null,
537
+ falseDenyReason: reason,
538
+ falseDenyNumerator: null,
539
+ falseDenyDenominator: null,
540
+ falseDenyClearEvents: null,
541
+ falseDenyMethod: null,
542
+ });
543
+
544
+ if (auditSource.status === SOURCE_STATUS.MISSING) {
545
+ return unmeasurable(`${AUDIT_LOG_FILENAME} is missing at ${auditSource.path}. Deny and reversal records both live there, so no numerator and no denominator exist.`);
546
+ }
547
+ if (auditSource.status === SOURCE_STATUS.UNREADABLE) {
548
+ return unmeasurable(`${AUDIT_LOG_FILENAME} at ${auditSource.path} could not be read (${auditSource.error}). Nothing was measured.`);
549
+ }
550
+ if (auditSource.status === SOURCE_STATUS.EMPTY) {
551
+ return unmeasurable(`${AUDIT_LOG_FILENAME} exists at ${auditSource.path} but holds no parsable records. There were no denies to measure — this is an absent measurement, not a rate of zero.`);
552
+ }
553
+
554
+ const { numerator, denominator, clearEvents } = pairDeniesWithClears(auditInWindow);
555
+ const method = `1:1 pairing — each reversal receipt in ${AUDIT_LOG_FILENAME} is matched to the most recent unpaired preceding deny of the SAME gateId. A reversal receipt is decision "${CLEARING_DECISION}", or decision "allow" with source "${ADMIN_OVERRIDE_SOURCE}" (a consumed single-use admin override). Decision "${APPROVAL_REQUIRED_DECISION}" is deliberately NOT counted: gates-engine emits it with requiresApproval:true and renders it to the harness as permissionDecision "deny", so it is itself a block, not a clearance. One receipt clears at most one block, so the numerator is a lower bound on reversals, never an inflated one.`;
556
+
557
+ if (denominator === 0) {
558
+ return {
559
+ falseDenyRate: null,
560
+ falseDenyReason: `No deny records in the last ${windowDays} day(s) (${auditSource.records.length} audit record(s) on file overall). A rate over zero denies would require an invented denominator.`,
561
+ falseDenyNumerator: numerator,
562
+ falseDenyDenominator: 0,
563
+ falseDenyClearEvents: clearEvents,
564
+ falseDenyMethod: method,
565
+ };
566
+ }
567
+
568
+ return {
569
+ falseDenyRate: Math.round((numerator / denominator) * 10000) / 10000,
570
+ falseDenyReason: null,
571
+ falseDenyNumerator: numerator,
572
+ falseDenyDenominator: denominator,
573
+ falseDenyClearEvents: clearEvents,
574
+ falseDenyMethod: method,
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Build the agent action inventory for one ThumbGate data directory.
580
+ *
581
+ * Read-only: opens files, writes nothing.
582
+ *
583
+ * @param {object} [opts]
584
+ * @param {string} [opts.dataDir] — a ThumbGate store dir (e.g. `<repo>/.thumbgate`).
585
+ * Defaults to the resolved feedback dir.
586
+ * @param {number|string} [opts.windowDays] — lookback in days (clamped 1..365, default 30).
587
+ * @param {number} [opts.topGateLimit=5]
588
+ * @returns {object} the inventory
589
+ */
590
+ function buildInventory(opts = {}) {
591
+ const windowDays = normalizeWindowDays(opts.windowDays);
592
+ const topGateLimit = Number.isFinite(opts.topGateLimit) && opts.topGateLimit > 0
593
+ ? Math.floor(opts.topGateLimit)
594
+ : 5;
595
+
596
+ let dataDir = opts.dataDir;
597
+ if (!dataDir) {
598
+ // Only reached by callers that did not scope themselves; the CLI always passes one.
599
+ const { resolveFeedbackDir } = require('./feedback-paths');
600
+ dataDir = resolveFeedbackDir();
601
+ }
602
+ dataDir = path.resolve(String(dataDir));
603
+
604
+ const nowMs = Date.now();
605
+ const sinceMs = nowMs - windowDays * 24 * 60 * 60 * 1000;
606
+
607
+ const readOpts = { maxBytes: opts.maxBytes };
608
+ const auditSource = readJsonlSource(path.join(dataDir, AUDIT_LOG_FILENAME), readOpts);
609
+ const gateSource = readJsonlSource(path.join(dataDir, GATE_EVENTS_LOG_FILENAME), readOpts);
610
+ const kpiSource = readJsonlSource(path.join(dataDir, KPI_LOG_FILENAME), readOpts);
611
+
612
+ const auditInWindow = auditSource.records.filter((r) => withinWindow(r, sinceMs));
613
+ const gateInWindow = gateSource.records.filter((r) => withinWindow(r, sinceMs));
614
+ const kpiInWindow = kpiSource.records.filter((r) => withinWindow(r, sinceMs));
615
+
616
+ const { daily, undated } = buildDaily(auditInWindow);
617
+
618
+ let allowCount = 0;
619
+ let denyCount = 0;
620
+ let warnCount = 0;
621
+ let approvalRequiredCount = 0;
622
+ let overrideCount = 0;
623
+ let otherDecisionCount = 0;
624
+ for (const r of auditInWindow) {
625
+ if (r.decision === APPROVAL_REQUIRED_DECISION) approvalRequiredCount += 1;
626
+ else if (r.decision === DENY_DECISION) denyCount += 1;
627
+ else if (r.decision === 'warn') warnCount += 1;
628
+ else if (r.decision === 'allow') allowCount += 1;
629
+ else otherDecisionCount += 1;
630
+ // Counted independently of the buckets above: a consumed admin override is
631
+ // recorded as decision 'allow', so it is both an allow and a reversal.
632
+ if (isClearingRecord(r)) overrideCount += 1;
633
+ }
634
+
635
+ const agents = buildAgents(kpiInWindow);
636
+
637
+ const inventory = {
638
+ generatedAt: new Date(nowMs).toISOString(),
639
+ dataDir,
640
+ windowDays,
641
+ since: new Date(sinceMs).toISOString(),
642
+
643
+ sources: {
644
+ auditTrail: auditSource.status,
645
+ gateEvents: gateSource.status,
646
+ toolKpi: kpiSource.status,
647
+ },
648
+ // Per-source detail so "0 calls" is always traceable to a file that exists,
649
+ // parsed, and simply had nothing inside the window.
650
+ sourceDetail: {
651
+ auditTrail: sourceDetail(auditSource, auditInWindow.length, sinceMs),
652
+ gateEvents: sourceDetail(gateSource, gateInWindow.length, sinceMs),
653
+ toolKpi: sourceDetail(kpiSource, kpiInWindow.length, sinceMs),
654
+ },
655
+ // false when a source's tail window did not reach back to `since`, i.e.
656
+ // records inside the requested window were dropped and every count below is
657
+ // a partial view. null when the audit trail was not readable at all.
658
+ windowFullyCovered: coversWindow(auditSource, sinceMs),
659
+
660
+ agents,
661
+ agentAttribution: buildAgentAttribution(kpiSource, agents),
662
+
663
+ toolCalls: buildToolCalls(auditInWindow),
664
+ allowCount,
665
+ denyCount,
666
+ warnCount,
667
+ // Also a block: gates-engine renders 'approve' to the harness as
668
+ // permissionDecision 'deny' with "APPROVAL REQUIRED". Reported on its own
669
+ // rather than folded into denyCount, because the false-deny denominator is
670
+ // defined strictly as decision === 'deny' and must not silently widen.
671
+ approvalRequiredCount,
672
+ overrideCount,
673
+ otherDecisionCount,
674
+
675
+ denyReasonsByGate: buildDenyReasonsByGate(auditInWindow),
676
+ topGates: buildTopGates(auditInWindow, topGateLimit),
677
+
678
+ daily,
679
+ undatedAuditRecords: undated,
680
+
681
+ // Independent second view of blocks. gate-events-log.jsonl is written by
682
+ // auditToFeedback() for every non-allow decision; if these two disagree,
683
+ // one of the two writers is broken and the operator should know.
684
+ gateEventDenies: gateSource.status === SOURCE_STATUS.OK
685
+ ? gateInWindow.filter((r) => r.decision === DENY_DECISION).length
686
+ : null,
687
+ };
688
+
689
+ Object.assign(inventory, computeFalseDeny(auditSource, auditInWindow, windowDays));
690
+
691
+ return inventory;
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // Text rendering
696
+ // ---------------------------------------------------------------------------
697
+
698
+ const STATUS_LABEL = {
699
+ [SOURCE_STATUS.OK]: 'ok',
700
+ [SOURCE_STATUS.EMPTY]: 'EMPTY (file present, no records)',
701
+ [SOURCE_STATUS.MISSING]: 'MISSING (file not found)',
702
+ [SOURCE_STATUS.UNREADABLE]: 'UNREADABLE',
703
+ };
704
+
705
+ function renderInventoryText(inv) {
706
+ const lines = [];
707
+ lines.push('ThumbGate agent action inventory');
708
+ lines.push(` data dir : ${inv.dataDir}`);
709
+ lines.push(` window : last ${inv.windowDays} day(s), since ${inv.since}`);
710
+ lines.push('');
711
+
712
+ lines.push('Sources');
713
+ for (const [name, status] of Object.entries(inv.sources)) {
714
+ const detail = inv.sourceDetail[name];
715
+ const suffix = status === SOURCE_STATUS.OK
716
+ ? `${detail.recordsInWindow} of ${detail.recordsTotal} record(s) in window`
717
+ : detail.path;
718
+ lines.push(` ${name.padEnd(11)} ${STATUS_LABEL[status] || status} — ${suffix}`);
719
+ if (detail.truncated) {
720
+ lines.push(` TRUNCATED: read the last ${detail.bytesRead} of ${detail.fileBytes} bytes; counts above cover only that tail`);
721
+ }
722
+ }
723
+ if (inv.windowFullyCovered === false) {
724
+ lines.push('');
725
+ lines.push(' WARNING: the audit-trail tail window did not reach back to the start of the');
726
+ lines.push(' requested window. Every count below is a PARTIAL view. Raise --days scope or');
727
+ lines.push(' pass a larger maxBytes to cover it fully.');
728
+ }
729
+ lines.push('');
730
+
731
+ lines.push('Decisions in window');
732
+ if (inv.sources.auditTrail !== SOURCE_STATUS.OK) {
733
+ lines.push(` not measured — audit trail is ${inv.sources.auditTrail}`);
734
+ } else {
735
+ lines.push(` allow ${inv.allowCount} deny ${inv.denyCount} warn ${inv.warnCount} other ${inv.otherDecisionCount}`);
736
+ lines.push(` approval-required ${inv.approvalRequiredCount} (also blocks; excluded from the false-deny denominator)`);
737
+ lines.push(` reversals (override / consumed admin override): ${inv.overrideCount}`);
738
+ const corroborating = inv.gateEventDenies === null
739
+ ? `not measured (gate-events source: ${inv.sources.gateEvents})`
740
+ : inv.gateEventDenies;
741
+ lines.push(` gate-events-log corroborating denies: ${corroborating}`);
742
+ }
743
+ lines.push('');
744
+
745
+ lines.push('Agents');
746
+ if (inv.agents.length === 0) {
747
+ lines.push(` none — ${inv.agentAttribution.status}: ${inv.agentAttribution.reason}`);
748
+ } else {
749
+ for (const a of inv.agents.slice(0, 10)) {
750
+ const top = a.tools.slice(0, 3).map((t) => `${t.tool}x${t.calls}`).join(', ');
751
+ lines.push(` ${a.agentId} ${a.calls} call(s)${top ? ` [${top}]` : ''}`);
752
+ }
753
+ if (inv.agents.length > 10) lines.push(` ... and ${inv.agents.length - 10} more`);
754
+ lines.push(` note: ${inv.agentAttribution.reason}`);
755
+ }
756
+ lines.push('');
757
+
758
+ lines.push('Tool calls (from audit trail)');
759
+ const tools = Object.entries(inv.toolCalls)
760
+ .sort((a, b) => b[1].total - a[1].total || a[0].localeCompare(b[0]));
761
+ if (tools.length === 0) {
762
+ lines.push(` none in window (audit trail: ${inv.sources.auditTrail})`);
763
+ } else {
764
+ for (const [tool, c] of tools.slice(0, 10)) {
765
+ lines.push(` ${tool.padEnd(22)} total ${c.total} allow ${c.allow} deny ${c.deny} warn ${c.warn}`);
766
+ }
767
+ if (tools.length > 10) lines.push(` ... and ${tools.length - 10} more tool(s)`);
768
+ }
769
+ lines.push('');
770
+
771
+ lines.push('Top gates by denies');
772
+ if (inv.topGates.length === 0) {
773
+ lines.push(` none in window (audit trail: ${inv.sources.auditTrail})`);
774
+ } else {
775
+ for (const g of inv.topGates) {
776
+ lines.push(` ${g.gate.padEnd(34)} deny ${g.denies} warn ${g.warns} approval ${g.approvalRequired} reversed ${g.overrides}`);
777
+ }
778
+ }
779
+ lines.push('');
780
+
781
+ lines.push('Daily');
782
+ if (inv.daily.length === 0) {
783
+ lines.push(` no dated records in window (audit trail: ${inv.sources.auditTrail})`);
784
+ } else {
785
+ for (const d of inv.daily) {
786
+ lines.push(` ${d.date} calls ${d.calls} denies ${d.denies} warns ${d.warns}`);
787
+ }
788
+ if (inv.undatedAuditRecords > 0) {
789
+ lines.push(` (${inv.undatedAuditRecords} record(s) had an unparsable timestamp and are excluded)`);
790
+ }
791
+ }
792
+ lines.push('');
793
+
794
+ lines.push('False-deny rate');
795
+ if (inv.falseDenyRate === null) {
796
+ lines.push(' null — NOT MEASURED');
797
+ lines.push(` reason: ${inv.falseDenyReason}`);
798
+ } else {
799
+ lines.push(` ${(inv.falseDenyRate * 100).toFixed(2)}%`);
800
+ }
801
+ lines.push(` numerator : ${inv.falseDenyNumerator === null ? 'n/a' : inv.falseDenyNumerator}`);
802
+ lines.push(` denominator : ${inv.falseDenyDenominator === null ? 'n/a' : inv.falseDenyDenominator}`);
803
+ lines.push(` clear events: ${inv.falseDenyClearEvents === null ? 'n/a' : inv.falseDenyClearEvents}`);
804
+ if (inv.falseDenyMethod) lines.push(` method : ${inv.falseDenyMethod}`);
805
+
806
+ return lines.join('\n') + '\n';
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Exports
811
+ // ---------------------------------------------------------------------------
812
+
813
+ module.exports = {
814
+ buildInventory,
815
+ renderInventoryText,
816
+ normalizeWindowDays,
817
+ readJsonlSource,
818
+ pairDeniesWithClears,
819
+ buildAgents,
820
+ buildToolCalls,
821
+ buildDenyReasonsByGate,
822
+ buildTopGates,
823
+ buildDaily,
824
+ coversWindow,
825
+ isClearingRecord,
826
+ SOURCE_STATUS,
827
+ CLEARING_DECISION,
828
+ ADMIN_OVERRIDE_SOURCE,
829
+ APPROVAL_REQUIRED_DECISION,
830
+ DEFAULT_MAX_BYTES,
831
+ KPI_LOG_FILENAME,
832
+ MIN_WINDOW_DAYS,
833
+ MAX_WINDOW_DAYS,
834
+ DEFAULT_WINDOW_DAYS,
835
+ };
836
+
837
+ // ---------------------------------------------------------------------------
838
+ // CLI
839
+ // ---------------------------------------------------------------------------
840
+
841
+ function runCli(argv) {
842
+ const args = argv.slice(2);
843
+ const flag = (name) => {
844
+ const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
845
+ if (!hit) return undefined;
846
+ if (hit === `--${name}`) return true;
847
+ return hit.slice(`--${name}=`.length);
848
+ };
849
+
850
+ const json = flag('json') !== undefined;
851
+ const days = flag('days');
852
+ const dataDir = flag('data-dir');
853
+ const maxBytes = flag('max-bytes');
854
+
855
+ const inventory = buildInventory({
856
+ dataDir: typeof dataDir === 'string' ? dataDir : undefined,
857
+ windowDays: typeof days === 'string' ? days : undefined,
858
+ maxBytes: typeof maxBytes === 'string' ? Number(maxBytes) : undefined,
859
+ });
860
+
861
+ if (json) console.log(JSON.stringify(inventory, null, 2));
862
+ else process.stdout.write(renderInventoryText(inventory));
863
+ }
864
+
865
+ // SonarCloud S3403 flags `require.main === module` as an always-false strict
866
+ // equality; the path-resolve form is the portable equivalent.
867
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
868
+ runCli(process.argv);
869
+ }