ohmyjev 0.3.0 → 0.3.2

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.
package/README.md CHANGED
@@ -59,7 +59,7 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
59
59
  | Feature | What it does |
60
60
  |---|---|
61
61
  | **Bash gate** | Denies a command when Jev rates it irreversible at 0.6 or more, or destructive at 0.7 or more. |
62
- | **Write gate** | Denies writes outside the repo and `allowPaths`, and writes that contain a real credential. The repo is every worktree of the one the session started in, plus every worktree of whichever repo the agent has moved into. The path check is plain code, not Jev, and it follows symlinks the same way the OS does. |
62
+ | **Write gate** | Denies writes outside the repo and `allowPaths`, and writes that contain a real credential. The repo means every worktree of the one the session started in, and every worktree of a repo the agent has moved into. The path check is plain code, not Jev, and it follows symlinks the way the OS does. |
63
63
  | **Exfil gate** | Denies a WebFetch or MCP call when Jev rates it 0.7 or more for sending your local data, files or credentials out. |
64
64
  | **Policies** | Every gate also checks the call against your own rules in the `policies` setting. |
65
65
  | **Injection screen** | When output from Bash, WebFetch, an MCP tool, or a Read outside the repo has instructions aimed at the model, it adds a note telling the model to treat that output as data. |
@@ -67,7 +67,7 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
67
67
  | **Router** | Asks Jev once per request for a tier, an effort level and a risk score. Then it raises or lowers effort and picks the model for general-purpose subagents. With no key it falls back to Claude Code's built-in classifier, which reports no confidence, so then it only ever routes up. |
68
68
  | **Auto-compact** | When the task changes after a finished step and the context is at least 40% full, it compacts the conversation. The summary keeps the new request in full. |
69
69
  | **`ask_jev`** | A tool the model can use to ask Jev about repo files or text without loading them into its own context. |
70
- | **`/omj`** | Shows this session's Jev calls, errors, cost, median latency and denies, plus where the key comes from. It never prints the key itself. `/omj stats` opens a dashboard of every session in your browser. `/omj off` and `/omj on` switch ohmyjev for the session. `/ohmyjev` is the same command. |
70
+ | **`/omj`** | This session's Jev calls, errors, cost, median latency and denies, and where the key comes from. It never prints the key. `/omj stats` opens a dashboard of every session in your browser. `/omj off` and `/omj on` switch ohmyjev for the session. `/ohmyjev` is the same command. |
71
71
 
72
72
  ### How a tool call goes through it
73
73
 
@@ -121,7 +121,7 @@ You've got three options, in the order ohmyjev looks for them:
121
121
  1. **The settings screen.** During the install, ohmyjev shows a screen with its options, including the TypeSafe
122
122
  key. Claude Code keeps it in secure storage, not in a settings file.
123
123
  2. **`TYPESAFE_API_KEY`** in your environment, for example in `~/.zshrc`.
124
- 3. **`OPENROUTER_API_KEY`** in your environment. ohmyjev then calls Jev through OpenRouter as `~typesafe/omj-latest`.
124
+ 3. **`OPENROUTER_API_KEY`** in your environment. ohmyjev then calls Jev through OpenRouter as `~typesafe/jev-latest`.
125
125
 
126
126
  ### 4. Check it works
127
127
 
@@ -201,9 +201,13 @@ This block is final. Do not try to work around it with another command, another
201
201
  or an encoding that does the same thing. Stop and tell the user what was blocked and why.
202
202
  ```
203
203
 
204
+ A write outside the repo gets a different message. It names the path and tells the model to ask you to add that
205
+ directory to `allowPaths`, because that block is a setting, not a hazard.
206
+
204
207
  ### `/omj`
205
208
 
206
- Run it any time to see what this session has been up to (`/ohmyjev` does the same). Here's an example:
209
+ Run it any time for this session's numbers. `/ohmyjev` is the same command, and `/omj settings` lists every setting's
210
+ value. An example:
207
211
 
208
212
  ```
209
213
  jev ✓23 ⛔1 ↑opus/high
@@ -224,7 +228,7 @@ never touch the prod cluster; no deploys on Friday; don't edit migrations that a
224
228
 
225
229
  ### `ask_jev`
226
230
 
227
- The model can call this tool on its own when it wants a quick judgment without reading a pile of files. For example:
231
+ The model can call this tool on its own when it needs a judgment about files without reading them. For example:
228
232
 
229
233
  ```json
230
234
  {
@@ -249,21 +253,21 @@ said, then what the router did with it:
249
253
  [ohmyjev] main loop kept opus/medium, wanted opus/low (confidence 0.38)
250
254
  ```
251
255
 
256
+ The last line is the router declining to act: it wanted to spend less, but 0.38 is under the 0.6 it takes to move
257
+ down. In a `claude -p` or SDK run the same lines arrive as `ui_log` messages and in the debug log. Turn them off with
258
+ `logDecisions`.
259
+
252
260
  ### `/omj stats`
253
261
 
254
- Writes every session's decisions into one page at `~/.ohmyjev/dashboard.html` and opens it: calls, denies, flags, cost
255
- and latency, a 30-day chart, a table per tool and per session, and the latest denies with their reasons. It's a plain
256
- file, read from the logs, so nothing on it leaves the machine (the page fetches its fonts from ohmyjev.xyz, and falls
257
- back to system fonts offline).
262
+ Builds one page from every session's log at `~/.ohmyjev/dashboard.html` and opens it in your browser. It has the
263
+ totals, a 30-day chart of calls and denies, a table per tool and per session, and the latest denies with their reasons.
264
+ It is a plain file built from the logs, so nothing on it leaves the machine. The page loads its fonts from ohmyjev.xyz
265
+ and falls back to system fonts offline.
258
266
 
259
267
  ### `/omj off`
260
268
 
261
- Turns every gate, screen, the done-check and the router off for this session, and the status shows `jev off`.
262
- `/omj on` brings them back. To start sessions off by default, turn off the `enabled` setting.
263
-
264
- The last line is the router declining to act: it wanted to spend less, but 0.38 is under the 0.6 it takes to move
265
- down. In a `claude -p` or SDK run the same lines arrive as `ui_log` messages and in the debug log. Turn them off with
266
- `logDecisions`.
269
+ Turns every gate, the screen, the done-check and the router off for this session. The status shows `jev off`.
270
+ `/omj on` brings them back. To start every session off, turn off the `enabled` setting.
267
271
 
268
272
  ### Status line
269
273
 
@@ -273,6 +277,7 @@ ohmyjev pins its status under the prompt, so there's nothing to set up:
273
277
  jev ✓23 ⛔1 ↑opus/high 🗜2 Jev calls, denies, this turn's route, auto-compactions
274
278
  jev ⚠ down Jev failed or timed out in the last 5 minutes, so the gates let calls through
275
279
  jev ⚠ no key no key configured
280
+ jev off turned off with /omj off, or the enabled setting
276
281
  ```
277
282
 
278
283
  If you'd rather have it in your own statusline script, copy `jev_segment()` from
@@ -325,12 +330,12 @@ a fixed size:
325
330
  | What asks | What it sends |
326
331
  |---|---|
327
332
  | Bash gate | The command (up to 16000 characters), the working directory and the tool call's description |
328
- | Write gate | The file path and the new content (up to 16000 characters). Writes outside the repo and `allowPaths` are denied by code, before anything is sent |
333
+ | Write gate | The file path and the new content (up to 16000 characters). Code denies writes outside the repo and `allowPaths` before it sends anything |
329
334
  | Exfil gate | The tool's name and its input (up to 4000 characters) |
330
335
  | Injection screen | The tool's output (up to 6000 characters), from Bash, WebFetch, MCP tools and Reads outside the repo |
331
336
  | Done-check | The current request (600), up to five earlier requests (200 each), up to 20 of this turn's tool calls with their input (200 each) and outcome, and Claude's last message (1500) |
332
337
  | Router | The request (up to 1500 characters) |
333
- | `ask_jev` | The model's question, any text it passes (up to 20000 characters) and the repo files it names (8000 each, 80000 in total). Files outside the repo are never sent |
338
+ | `ask_jev` | The model's question, any text it passes (up to 20000 characters) and the repo files it names (8000 each, 80000 in total). It never sends files outside the repo |
334
339
 
335
340
  When you've set `policies`, every gate sends those too. Turning a feature off stops its calls.
336
341
 
@@ -0,0 +1,225 @@
1
+ /**
2
+ * /omj stats: one static HTML page from every session's decision log, in ohmyjev.xyz's theme. Pure: the hosts read the
3
+ * logs, write the page to ~/.ohmyjev/dashboard.html and open it. Nothing on the page leaves the machine except the
4
+ * four font files, fetched from ohmyjev.xyz (system fonts stand in when offline).
5
+ */
6
+ import type { LogEntry } from './policy.ts'
7
+
8
+ const FONTS = 'https://ohmyjev.xyz/fonts'
9
+ const DAYS = 30
10
+
11
+ export const esc = (s: unknown): string =>
12
+ String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]!)
13
+
14
+ const day = (ts: number): string => new Date(ts).toISOString().slice(0, 10)
15
+ const when = (ts: number): string => new Date(ts).toISOString().replace('T', ' ').slice(0, 16)
16
+ const money = (usd: number): string => `$${usd.toFixed(usd >= 0.01 ? 2 : 4)}`
17
+ const pct = (n: number, of: number): number => (of ? Math.round((n / of) * 100) : 0)
18
+ const quantile = (sorted: number[], q: number): number => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0
19
+ /** What a line was about: the tool, or the battery for lines with no tool. */
20
+ const what = (r: LogEntry): string =>
21
+ r.tool || ({ 'turn.start': 'router', Stop: 'done-check', 'session.measure': 'auto-compact', 'tool.result': 'screen', ask_jev: 'ask_jev' } as Record<string, string>)[r.event] || r.event
22
+
23
+ type Stat = { calls: number; denies: number; flags: number; cost: number }
24
+ const stat = (): Stat => ({ calls: 0, denies: 0, flags: 0, cost: 0 })
25
+ const isDeny = (r: LogEntry) => r.verdict === 'deny' || r.verdict === 'block'
26
+
27
+ export function dashboardHtml(rows: LogEntry[], opts: { now: number; sid?: string; source?: string }): string {
28
+ const calls = rows.filter(r => r.answers)
29
+ const ms = calls.map(r => r.ms ?? 0).sort((a, b) => a - b)
30
+ const denies = rows.filter(isDeny)
31
+ const total = { ...stat(), calls: calls.length, denies: denies.length, flags: rows.filter(r => r.verdict === 'flag').length, cost: calls.reduce((n, r) => n + (r.costUsd ?? 0), 0) }
32
+ const errors = rows.filter(r => r.error).length
33
+ const compactions = rows.filter(r => r.verdict === 'compact').length
34
+
35
+ const by = <K extends string>(key: (r: LogEntry) => K): Map<K, Stat> => {
36
+ const m = new Map<K, Stat>()
37
+ for (const r of rows) {
38
+ const s = m.get(key(r)) ?? stat()
39
+ if (r.answers) s.calls++
40
+ if (isDeny(r)) s.denies++
41
+ if (r.verdict === 'flag') s.flags++
42
+ s.cost += r.costUsd ?? 0
43
+ m.set(key(r), s)
44
+ }
45
+ return m
46
+ }
47
+ const tools = [...by(what)].sort((a, b) => b[1].denies - a[1].denies || b[1].calls - a[1].calls)
48
+ const days = by(r => day(r.ts))
49
+ const span = Array.from({ length: DAYS }, (_, i) => day(opts.now - (DAYS - 1 - i) * 86_400_000))
50
+ const peak = Math.max(1, ...span.map(d => days.get(d)?.calls ?? 0))
51
+ const sessions = new Map<string, Stat & { first: number; last: number }>()
52
+ for (const r of rows) {
53
+ const s = sessions.get(r.session) ?? { ...stat(), first: r.ts, last: r.ts }
54
+ s.first = Math.min(s.first, r.ts)
55
+ s.last = Math.max(s.last, r.ts)
56
+ if (r.answers) s.calls++
57
+ if (isDeny(r)) s.denies++
58
+ if (r.verdict === 'flag') s.flags++
59
+ s.cost += r.costUsd ?? 0
60
+ sessions.set(r.session, s)
61
+ }
62
+ const recentSessions = [...sessions].sort((a, b) => b[1].last - a[1].last).slice(0, 15)
63
+ const recentDenies = denies.slice(-25).reverse()
64
+
65
+ const tile = (n: string, label: string, kind = '') =>
66
+ `<div class="feat"><span class="kind ${kind}">${esc(label)}</span><strong>${esc(n)}</strong></div>`
67
+ const bars = span
68
+ .map(d => {
69
+ const s = days.get(d) ?? stat()
70
+ const h = Math.max(s.calls ? 3 : 1, Math.round((s.calls / peak) * 100))
71
+ const dh = s.calls ? Math.max(s.denies ? 3 : 0, Math.round((s.denies / peak) * 100)) : 0
72
+ return `<div class="bar" title="${esc(d)}: ${s.calls} calls, ${s.denies} denies, ${money(s.cost)}"><i style="height:${h}%"></i><b style="height:${dh}%"></b></div>`
73
+ })
74
+ .join('')
75
+ const toolRows = tools
76
+ .map(([t, s]) => `<tr><td><code>${esc(t)}</code></td><td>${s.calls}</td><td class="${s.denies ? 'o' : ''}">${s.denies}</td><td>${s.flags}</td><td>${money(s.cost)}</td></tr>`)
77
+ .join('')
78
+ const sessionRows = recentSessions
79
+ .map(([sid, s]) => `<tr${sid === opts.sid ? ' class="me"' : ''}><td><code>${esc(sid.slice(0, 8))}</code>${sid === opts.sid ? ' <span class="g">this</span>' : ''}</td><td>${when(s.last)}</td><td>${s.calls}</td><td class="${s.denies ? 'o' : ''}">${s.denies}</td><td>${money(s.cost)}</td></tr>`)
80
+ .join('')
81
+ const denyRows = recentDenies
82
+ .map(r => `<tr><td>${when(r.ts)}</td><td><code>${esc(what(r))}</code></td><td>${esc(r.reason ?? '')}</td><td><code>${esc(r.session.slice(0, 8))}</code></td></tr>`)
83
+ .join('')
84
+ const lede = rows.length
85
+ ? `${total.calls} Jev calls across ${sessions.size} sessions for ${money(total.cost)}. ${total.denies} ${total.denies === 1 ? 'deny' : 'denies'} (${pct(total.denies, total.calls + total.denies)}%), ${total.flags} injection ${total.flags === 1 ? 'flag' : 'flags'}, ${errors} ${errors === 1 ? 'error' : 'errors'}.`
86
+ : 'No decisions logged yet. Run a few commands with ohmyjev on, then come back.'
87
+
88
+ return `<!doctype html>
89
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <title>ohmyjev · stats</title>
91
+ <style>
92
+ @font-face { font-family: "Changa"; src: url("${FONTS}/Changa-ExtraBold.woff2") format("woff2"); font-weight: 800; font-display: swap; }
93
+ @font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
94
+ @font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-SemiBold.woff2") format("woff2"); font-weight: 600; font-display: swap; }
95
+ @font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
96
+ @font-face { font-family: "JetBrains Mono"; src: url("${FONTS}/JetBrainsMono-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
97
+ :root {
98
+ --ground: #080b09; --surface: #111713; --panel: rgba(17, 23, 19, .9); --panel-hi: rgba(26, 36, 29, .94);
99
+ --paper: #f4f8f5; --muted: #a5b5a9; --faint: #829487; --line: #2c3930; --steel: #829487;
100
+ --brand: #00ee22; --brand-hi: #39ff53; --link: #79ff98; --lavender: #b69cff;
101
+ --ok: #56e887; --warn: #ffc857; --err: #ff737d; --info: #74c7ff;
102
+ --ui: clamp(.875rem, .82rem + .15vw, 1rem);
103
+ --logo: "Changa", "Space Grotesk", system-ui, sans-serif; --display: "Space Grotesk", system-ui, sans-serif;
104
+ --body: "Space Grotesk", system-ui, sans-serif; --label: "Space Grotesk", system-ui, sans-serif;
105
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
106
+ color-scheme: dark;
107
+ }
108
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
109
+ html { background: var(--ground); scrollbar-color: var(--line) var(--ground); }
110
+ body {
111
+ min-height: 100vh; padding: clamp(26px, 4.5vh, 64px) clamp(22px, 6vw, 120px) 80px; color: var(--paper); font-family: var(--body);
112
+ -webkit-font-smoothing: antialiased; background: var(--ground);
113
+ background-image: linear-gradient(rgba(130, 148, 135, .07) 1px, transparent 1px), linear-gradient(90deg, rgba(130, 148, 135, .07) 1px, transparent 1px);
114
+ background-size: 64px 64px;
115
+ }
116
+ ::selection { background: rgba(0, 238, 34, .3); color: var(--paper); }
117
+ a { color: inherit; text-decoration: none; }
118
+ :focus-visible { outline: 2px solid var(--link); outline-offset: 3px; }
119
+ code { font-family: var(--mono); font-size: .875em; color: var(--paper); }
120
+ .lbl { font-family: var(--label); font-weight: 600; font-size: var(--ui); letter-spacing: .06em; text-transform: uppercase; }
121
+ .header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: clamp(28px, 6vh, 64px); }
122
+ .brand { font: 800 30px/1 var(--logo); letter-spacing: -.01em; color: var(--paper); white-space: nowrap; }
123
+ .brand .acc { color: var(--brand); }
124
+ .header .lbl { color: var(--faint); }
125
+ .header .lbl span { color: var(--brand); }
126
+ .section-label { display: flex; align-items: center; gap: 10px; color: var(--paper); margin-bottom: 12px; }
127
+ .section-label::before { content: ""; width: 6px; height: 6px; background: var(--brand); }
128
+ .section-label span { margin-left: 8px; color: var(--faint); }
129
+ h2 { color: var(--paper); font: 600 clamp(1.75rem, 1.3rem + 1.2vw, 2.25rem)/1.1 var(--display); letter-spacing: -.02em; text-transform: lowercase; text-wrap: balance; }
130
+ h2 em { font-style: inherit; color: var(--brand); }
131
+ .lede { max-width: 62ch; color: var(--muted); font-size: clamp(1rem, .95rem + .2vw, 1.125rem); line-height: 1.5; margin: 14px 0 clamp(22px, 4vh, 40px); }
132
+ h3 { font: 600 var(--ui) var(--label); letter-spacing: .06em; text-transform: uppercase; color: var(--paper); }
133
+ section { margin-bottom: clamp(24px, 4vh, 44px); }
134
+ .grid { display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
135
+ .grid > * { position: relative; display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: clamp(14px, 2vh, 22px) clamp(14px, 1.4vw, 22px); background: var(--panel); transition: background .25s ease; }
136
+ .grid > *:hover { background: var(--panel-hi); }
137
+ .tiles { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
138
+ /* tiles draw their own hairlines, so a short last row ends in panel, not a block of line color */
139
+ .tiles { background: var(--panel); }
140
+ .tiles > * { box-shadow: 1px 0 0 var(--line), 0 1px 0 var(--line); }
141
+ .g2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); }
142
+ .kind { font: 600 .75rem var(--label); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); display: inline-flex; align-items: center; gap: 6px; }
143
+ .kind::before { content: ""; width: 5px; height: 5px; background: var(--steel); }
144
+ .kind.gate::before { background: var(--err); }
145
+ .kind.check::before { background: var(--lavender); }
146
+ .kind.route::before { background: var(--info); }
147
+ .feat strong { font: 700 clamp(1.6rem, 2.4vw, 2.2rem)/1.1 var(--display); letter-spacing: -.03em; color: var(--paper); font-variant-numeric: tabular-nums; }
148
+ .card-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
149
+ .card-head .meta { color: var(--faint); font: 600 .75rem var(--label); letter-spacing: .06em; text-transform: uppercase; }
150
+ .chart { display: flex; align-items: flex-end; gap: 3px; height: 160px; padding-top: 8px; border-bottom: 1px solid var(--line); }
151
+ .bar { position: relative; flex: 1; height: 100%; min-width: 0; }
152
+ .bar i, .bar b { position: absolute; left: 0; right: 0; bottom: 0; display: block; }
153
+ .bar i { background: rgba(0, 238, 34, .45); transition: background .2s ease; }
154
+ .bar b { background: var(--err); }
155
+ .bar:hover i { background: var(--brand-hi); }
156
+ .axis { display: flex; justify-content: space-between; color: var(--faint); font: .75rem var(--mono); padding-top: 6px; }
157
+ .legend { display: flex; gap: 18px; color: var(--faint); font: 600 .75rem var(--label); letter-spacing: .06em; text-transform: uppercase; }
158
+ .legend i { display: inline-block; width: 10px; height: 10px; vertical-align: -1px; margin-right: 6px; background: rgba(0, 238, 34, .45); }
159
+ .legend i.o { background: var(--err); }
160
+ .tbl { overflow: auto; border: 1px solid var(--line); scrollbar-width: thin; }
161
+ table { width: 100%; border-collapse: collapse; }
162
+ th { padding: 7px 14px; text-align: left; font: 600 .75rem var(--label); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); border-bottom: 1px solid var(--line); background: var(--panel); white-space: nowrap; }
163
+ td { padding: 6px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: .9375rem; line-height: 1.35; vertical-align: top; background: rgba(17, 23, 19, .82); font-variant-numeric: tabular-nums; }
164
+ tr:last-child td { border-bottom: 0; }
165
+ tr:hover td { background: var(--panel-hi); }
166
+ td:first-child, td:nth-child(2) { white-space: nowrap; }
167
+ td.o { color: var(--err); }
168
+ .g { color: var(--ok); }
169
+ .me td { background: rgba(0, 238, 34, .06); }
170
+ .empty { padding: 18px 14px; color: var(--faint); }
171
+ .foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--faint); font-size: .875rem; }
172
+ .foot a { color: var(--link); }
173
+ .foot a:hover { color: var(--brand-hi); }
174
+ .foot a span { color: var(--brand); }
175
+ </style></head>
176
+ <body>
177
+ <header class="header">
178
+ <a class="brand" href="https://ohmyjev.xyz" aria-label="ohmyjev"><span class="acc">&gt;_</span>ohmy<span class="acc">jev</span></a>
179
+ <div class="lbl">stats <span>·</span> ${esc(when(opts.now))} UTC${opts.source ? ` <span>·</span> ${esc(opts.source)}` : ''}</div>
180
+ </header>
181
+ <p class="section-label lbl">What Jev decided <span>every session on this machine</span></p>
182
+ <h2>what jev <em>decided</em></h2>
183
+ <p class="lede">${esc(lede)}</p>
184
+
185
+ <section><div class="grid tiles">
186
+ ${tile(String(total.calls), 'Jev calls')}
187
+ ${tile(String(total.denies), 'denies', 'gate')}
188
+ ${tile(String(total.flags), 'injection flags', 'check')}
189
+ ${tile(money(total.cost), 'cost')}
190
+ ${tile(`${quantile(ms, 0.5)} ms`, 'p50 latency')}
191
+ ${tile(`${quantile(ms, 0.95)} ms`, 'p95 latency')}
192
+ ${tile(String(sessions.size), 'sessions')}
193
+ ${tile(String(errors), 'errors')}
194
+ ${tile(String(compactions), 'auto-compactions', 'route')}
195
+ </div></section>
196
+
197
+ <section><div class="grid"><div>
198
+ <div class="card-head"><h3>last ${DAYS} days</h3><span class="legend"><span><i></i>calls</span><span><i class="o"></i>denies</span></span></div>
199
+ <div class="chart">${bars}</div>
200
+ <div class="axis"><span>${esc(span[0])}</span><span>${esc(span.at(-1))}</span></div>
201
+ </div></div></section>
202
+
203
+ <section><div class="grid g2">
204
+ <div>
205
+ <div class="card-head"><h3>by tool</h3><span class="meta">most denied first</span></div>
206
+ <div class="tbl">${toolRows ? `<table><thead><tr><th>tool</th><th>calls</th><th>denies</th><th>flags</th><th>cost</th></tr></thead><tbody>${toolRows}</tbody></table>` : '<p class="empty">nothing yet</p>'}</div>
207
+ </div>
208
+ <div>
209
+ <div class="card-head"><h3>sessions</h3><span class="meta">latest ${recentSessions.length} of ${sessions.size}</span></div>
210
+ <div class="tbl">${sessionRows ? `<table><thead><tr><th>session</th><th>last seen (UTC)</th><th>calls</th><th>denies</th><th>cost</th></tr></thead><tbody>${sessionRows}</tbody></table>` : '<p class="empty">nothing yet</p>'}</div>
211
+ </div>
212
+ </div></section>
213
+
214
+ <section><div class="grid"><div>
215
+ <div class="card-head"><h3>recent denies</h3><span class="meta">latest ${recentDenies.length} of ${denies.length}</span></div>
216
+ <div class="tbl">${denyRows ? `<table><thead><tr><th>when (UTC)</th><th>tool</th><th>reason</th><th>session</th></tr></thead><tbody>${denyRows}</tbody></table>` : '<p class="empty">no denies</p>'}</div>
217
+ </div></div></section>
218
+
219
+ <footer class="foot">
220
+ <span>Read from ~/.ohmyjev/log, readable only by you. Nothing on this page leaves the machine.</span>
221
+ <span>Regenerate with <code>/omj stats</code> · <a href="https://ohmyjev.xyz">ohmyjev<span>.</span>xyz</a></span>
222
+ </footer>
223
+ </body></html>
224
+ `
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohmyjev",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Guardrails for coding agents, decided by TypeSafe's Jev: blocks destructive commands, catches prompt injection, pushes back on an unverified done and routes effort per turn. For Claude Code, pi and omp.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "files": [
21
21
  "adapters/*.ts",
22
+ "hooks/dashboard.ts",
22
23
  "hooks/jev.ts",
23
24
  "hooks/policy.ts",
24
25
  "extras/"