Jev picks which of your rules apply to each prompt, so Claude only sees the ones that matter.

<img src="https://img.shields.io/badge/Claude%20Code-Plugin-5A67D8?style=for-the-badge" alt="Claude Code plugin" /> <img src="https://img.shields.io/badge/Version-0.5.0-3178C6?style=for-the-badge" alt="Version 0.5.0" /> <img src="https://img.shields.io/badge/Dependencies-None-1C7C54?style=for-the-badge" alt="No dependencies" /> <img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="MIT License" />
<a href="https://github.com/EliaAlberti/jev-rules/releases/tag/v0.5.0"> <img src="social/jev-rules-pane.gif" alt="The rules pane beside a Claude Code session: a checkout bug report turns three of fifteen rules green, then an edit to package.json turns the dependency rule green at 0.97." width="800" /> </a><br /> <em>A real Claude Code session with the rules pane, edited to 30 seconds. The full-quality video is attached to the <a href="https://github.com/EliaAlberti/jev-rules/releases/tag/v0.5.0">v0.5.0 release</a>. The earlier <a href="https://eliaalberti.github.io/jev-rules/">40-second demo</a> is still online.</em>
If you use Claude Code for a while, you end up with a pile of standing instructions: test the payment code, use British spelling, follow the deploy checklist. Show all of them on every prompt and Claude wades through rules that have nothing to do with the request; pick them by keyword and a rule is missed the moment the request does not contain its trigger word. jev-rules asks a small, fast decision model called Jev one yes/no question per rule, "is this request about that?", and passes Claude only the rules that get a yes. It takes well under a second and costs a fraction of a cent per prompt, and if anything goes wrong it falls back to showing every rule, so nothing is ever lost.
New in 0.5.0: one line under each prompt names the rules Claude was given and Jev's score for each, and the rules pane switches on with one answer: the first time Jev picks a rule, Claude asks whether you want it.
0.4.0 added the rules pane: every rule beside the conversation, green once Claude has it, with Jev's score.
0.3.0 delivers each rule and map document once per session instead of on every matching prompt, so a long session never costs more than loading everything once, and usually far less. 0.2.0 added rules that follow the file Claude is changing, a filtered codebase map, rule subfolders, applies and does_not_apply, and a retry when the API is busy. See the changelog.
Twelve rules in the project, one request, one rule delivered. These are stills from real Claude Code sessions in the demo project under social/demo/shop.
The stills below were captured in 0.3.0, before the pane existed: their right-hand side is a companion viewer that read the debug log.
In the 0.3.0 stills, the left side is Claude Code and the right side is a companion viewer used before the plugin had a pane of its own (social/rig/watch.mjs): it tails the real ~/.jev-rules.log that JEV_DEBUG=1 writes and draws one bar per rule. The plugin now shows the same inside Claude Code: see in the conversation and the rules pane. More captures and the shot list are in social/.
Inside Claude Code:
/plugin marketplace add EliaAlberti/jev-rules
/plugin install jev-rules@jev-rules
Run /reload-plugins or restart Claude Code if you installed from inside a session.
Already installed? Update from your shell, then restart Claude Code:
claude plugin marketplace update jev-rules
claude plugin update jev-rules@jev-rules
Then give it a key. Jev is made by TypeSafe; get a key at console.typesafe.ai and put it in a file in your home directory:
echo "JEV_API_KEY=your-key" > ~/.jev-rules.env
If you already pay for Jev through Vercel AI Gateway, that key works too: use AI_GATEWAY_API_KEY= instead. The plugin reaches the same model either way, with no SDK and no other account.
Requirements: Claude Code with plugin support (built and verified on v2.1.276) and Node.js 20.12 or newer, with node on the PATH of a non-interactive shell (an nvm-only install sometimes is not; which node from a fresh terminal tells you).
To try a working copy without installing:
claude --plugin-dir /path/to/jev-rules/plugins/jev-rules
Rules live in your project at .claude/jev-rules/, one Markdown file per rule. The examples/rules/ folder in this repo has three you can copy straight in:
mkdir -p .claude/jev-rules
cp /path/to/jev-rules/examples/rules/*.md .claude/jev-rules/
Here is payments-need-tests.md in full:
---
description: Changing code that computes prices, totals, discounts, taxes, refunds or payments, or anything in the checkout flow.
---
Money paths need a test before they change.
- Write or update a test that covers the exact calculation you are touching, with at least one discount, one tax and one refund case where they apply.
- Round once, at the end, in one place. Never round intermediate values.
- Every amount states its currency. Never mix currencies in one calculation.
- Run the payments tests before you report the change as done.
The header takes up to four fields:
| Field | What it does |
|---|---|
description | One plain sentence saying when the rule applies. Jev judges this line. |
always: true | Optional. Skip Jev and inject this rule on every prompt. |
applies | Optional. One line saying what a yes looks like. |
does_not_apply | Optional. One line saying what a no looks like. |
Everything below the header is the rule itself, passed to Claude word for word when the rule applies.
Write the description as a concrete answer to "what is the request about?", and name the things a request would mention: files, actions, areas of the code. Jev reads literally, so "Deploying, releasing, shipping to production, tagging a version or publishing a package" works far better than "Important release stuff". Keep it to one line.
Add applies or does_not_apply only when a description keeps catching the wrong prompts. Either works alone. They sharpen a description and do not replace it:
---
description: Deploying, releasing, shipping to production, tagging a version or publishing a package.
does_not_apply: Only a local build or a test run, with nothing leaving the machine.
---
Rules can sit in subfolders, up to eight levels deep: .claude/jev-rules/frontend/react.md becomes the rule frontend/react. Names starting with a dot are ignored, and symlinks are followed.
The directory is separate from Claude Code's own .claude/rules/ on purpose: Claude Code loads every file in that folder at the start of each session, which is exactly what this plugin avoids.
Rules tell Claude how to work. A codebase map tells it how the project fits together: where the checkout code lives, how a release goes out. jev-rules filters a map the same way it filters rules, so Claude gets the document that answers the question instead of the whole map.
Map documents live in .claude/jev-map/, one Markdown file each, with the same header as a rule. examples/map/ has three to copy in:
mkdir -p .claude/jev-map
cp /path/to/jev-rules/examples/map/*.md .claude/jev-map/
description says what the document covers; write it like a rule's, naming what a request would mention. applies and does_not_apply work as for rules, and always is ignored. A document without a description is described by its # Title and first paragraph, up to 300 characters, but a one-line description of your own separates more sharply.
If you use Eigenwise's codebase-mapper, its map in .claude/.codebase-info/ is read as it is. Each document is described by its title and first paragraph and named codebase-info/<file>. INDEX.md is skipped. jev-rules reads a map; it does not write or refresh one.
When a prompt brings rules or map documents, one line under it names them, with Jev's score:
⎿ UserPromptSubmit says: jev-rules: Jev gave Claude: deploy-checklist 0.94, map deploy-pipeline 0.83
A file change that brings a rule gets its own line, before the edit:
⎿ PreToolUse:Edit says: jev-rules: Jev gave Claude for src/checkout/tax.ts: payments-need-tests 0.97
A prompt that brings nothing new stays quiet. When Jev cannot answer, the line says so and counts the rules Claude got unjudged. To turn the line off, set Show Jev's picks to off in /config, or JEV_RULES_SHOW_PICKS=0.
Type /rules and a pane opens beside the conversation with every rule and map document of the project, as a tree. A rule turns green once Claude has been given it this session, and flashes as it arrives, whether a prompt or a file change brought it. Beside each one is Jev's latest score. Adding, renaming or deleting a rule file shows up within a couple of seconds. /rules again closes it.
Jev picked 2 of 15
green: given to Claude this session
rules
· accessible-ui 0.31
· deploy-checklist 0.02
✓ payments-need-tests 0.97
· test-conventions 0.05
map
✓ checkout 0.94
The pane is built on Claude Code's plugin panes, an early-access feature that Claude Code loads only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set. A plugin cannot set it for you, so jev-rules asks. The first time Jev picks a rule in a session, Claude puts one question to you in Claude Code's own question box:
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to the env block of ~/.claude/settings.json and keeps everything else in the file. Restart Claude Code and /rules is there. The setting applies to every plugin you have installed, so any other plugin with a pane loads it too.The question comes only in a session someone is at, never in claude -p or other scripted runs, and never once the setting is on. /jev-rules:pane asks it at any time. To switch the pane off, delete that line from the env block. Without the setting the pane and /rules are simply not there, and everything else works as before. Early access means Anthropic may change the feature between releases. Built and tested on Claude Code 2.1.280.
By default the pane also opens by itself the first time Jev picks a rule in a session, but only when Claude Code docks panes beside the conversation (its fullscreen layout) and the window is at least 144 columns wide. Close it and it stays closed for the session. To open it only with /rules, set Rules pane opens to only-with-command in /config.
The pane decides nothing and sends nothing. It reads the small session file the hooks already keep, described under Privacy.
On every prompt the plugin reads your rule and map files fresh, sends Jev the prompt text with one question per rule and per map document, in a single call, and injects what comes back with a probability at or above the threshold. When nothing applies, nothing is injected.
When Claude is about to change a file with Edit, Write or NotebookEdit, the plugin asks again about that file's path, and injects the rules that apply to it and were not already given this turn. So a vague prompt like "fix the bug we discussed" still gets the payments rule the moment Claude touches src/checkout/discount.ts.
JEV_RULES_THRESHOLD to any value from 0 to 1. In practice Jev's answers sit close to 0 or close to 1, so the exact value rarely matters.always: true rules never go to Jev. They are injected first, on every prompt./clear or a compaction the context is new, so everything is deliverable again. always: true rules follow the same pattern: once per session. Set JEV_RULES_REPEAT=1 to get the old behaviour, delivery on every matching prompt.JEV_RULES_TIMEOUT_MS), a network error, an HTTP error or an unreadable reply all lead to the same thing: every rule is injected, with a one-line note saying why, and your prompt goes through. Map documents are listed, never poured in. On an edit, the rules Jev could not judge are injected once, and not again that turn. The plugin never blocks a prompt or an edit and never prints to your terminal.Retry-After (at most 300 ms) and asks once more, if at least 400 ms of the timeout is left. The timeout covers both attempts.JEV_DEBUG=1 and each decision is appended to ~/.jev-rules.log:2026-09-18T09:17:10.967Z session=264024da event=prompt backend=vercel model=typesafe-ai/jev ms=517 attempts=1 outcome=jev truncated=no prompt="the checkout total is wrong when a discount is applied, fix it"
british-spelling p=0.04 injected=no
deploy-checklist p=0.02 injected=no
payments-need-tests p=0.93 injected=yes
map:checkout p=0.93 injected=yes
map:deploy-pipeline p=0.10 injected=no
Settings go in the environment, in .env in the project, or in ~/.jev-rules.env, checked in that order:
| Setting | Default | What it does |
|---|---|---|
JEV_API_KEY or TYPESAFE_API_KEY | none | TypeSafe key. |
AI_GATEWAY_API_KEY | none | Vercel AI Gateway key, used when no TypeSafe key is set. |
JEV_RULES_THRESHOLD | 0.6 | Probability at or above which a rule or document is injected. |
JEV_RULES_TIMEOUT_MS | 2000 | Budget for the whole Jev call, 100 to 8000. |
JEV_RULES_EDITS | on | 0 turns off the check before file changes. |
JEV_RULES_MAP | on | 0 turns off the codebase map. |
JEV_RULES_REPEAT | off | 1 delivers on every matching prompt instead of once per session. |
JEV_RULES_SHOW_PICKS | on | 0 hides the line naming what Claude was given. Show Jev's picks in /config does the same. |
JEV_DEBUG | off | 1 writes decisions to ~/.jev-rules.log. |
One Jev call per prompt, whatever the number of rules and map documents: every question is answered in the same request. A file change adds at most one more call, only for a file not yet judged this session.
TypeSafe's published price is $0.042 per million input tokens, with output tokens free (docs.typesafe.ai/models). A prompt of a few hundred words plus three rules is about 500 tokens, so roughly $0.00002 per prompt, or a cent for every five hundred prompts. Each map document adds about 20 tokens plus its description.
Context tokens. Measured on the demo project in social/demo/shop (12 rules, 3 map documents, real Jev calls):
| Session | Loading everything up front | With jev-rules |
|---|---|---|
| 8 prompts, all about checkout | about 1,390 tokens | about 330 tokens |
| 12 prompts touching nearly every topic | about 1,390 tokens | about 1,400 tokens |
So the saving depends on how much of your rule set a session touches: a focused session uses a quarter of the tokens, a session that touches everything breaks even, and it never grows with the length of the session. The more rules a project has, the larger the gap. Jev is also asked about fewer rules as the session goes on, and not at all once everything relevant has been delivered.
Latency measured while building this: 260 to 520 ms per call, with the first call of a session slower because of the TLS handshake. That matches what jev-router reports for the same API. npm run live prints the numbers for your own connection.
What leaves your machine goes to TypeSafe, or to Vercel's gateway if that is the key you use:
description, applies and does_not_apply lines of each rule not marked always and of each map document. For a map document without a description, that is its title and first paragraph, up to 300 characters.src/checkout/discount.ts, and the same rule lines. A file outside the project is sent as its base name only. Changes to your rule files send nothing.Rule bodies, document bodies, rule and document names, file contents, the edit itself and everything else stay local. TypeSafe states it does not train on requests (models page).
The debug log is off by default, lives on your machine, and records the first 80 characters of each prompt and the path of each file judged. Session state (what has been delivered this session, Jev's answers per file, and its latest score for each rule, which the rules pane shows) is one small file per session in your system temp directory, under jev-rules/, readable only by you and removed after a week. Whether you declined the pane question, and the last session it was asked in, is kept in the plugin's own data folder under ~/.claude/plugins/data/. The plugin changes ~/.claude/settings.json only when you answer Yes, turn it on.
/jev-rules:pane, which asks the pane question, and /rules, which exists once the pane is on.npm test # offline, mocked Jev: prompts, file changes, map, once-per-session, fail-open, retry, both wire formats, the pane, the line and the pane question
npm run live # real API: example rules and map against sample prompts and file paths
npm run live -- --files src/app.ts docs/guide.md
npm run live -- --map
claude plugin validate .
claude plugin validate plugins/jev-rules
The hook is plugins/jev-rules/hooks/jev-rules.mjs; everything it needs is under plugins/jev-rules/hooks/lib/. The rules pane is plugins/jev-rules/hooks/pane.tsx, which Claude Code loads itself; its logic is in hooks/lib/pane-model.mjs and tested with the rest. The pane question and the settings change are in hooks/lib/pane-setup.mjs. No dependencies to install. To try the pane from a working copy:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/jev-rules
Release notes live in CHANGELOG.md.
Questions, ideas, misfires: join the Discussions. Bugs go in Issues.
Created by Elia Alberti. Built with and for Claude Code.
MIT. See LICENSE.
hooks/pane.tsx 201 lines1// The rules pane: /rules shows every rule and map document of the project as a
2// tree, green once Claude has been given it this session, flashing as it
3// arrives, with Jev's latest score beside each one.
4//
5// Early access: Claude Code loads this module only with
6// CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, which the pane question (lib/pane-setup.mjs,
7// /jev-rules:pane) adds to the person's settings when they say yes. Without it
8// the plugin's command hooks work exactly as before and this file is skipped.
9//
10// The pane decides nothing. It reads the session record the command hooks
11// write, <tmpdir>/jev-rules/<session id>.json, and makes no Jev call.
12import type { EngineInterface, Register } from 'claude-code'
13import { fitRow, headline, newlyPicked, readRecord, scoreText, shouldAutoOpen, treeRows } from './lib/pane-model.mjs'
14
15const PANE_ID = 'jev-rules'
16const COMMAND = 'rules'
17const TITLE = 'jev-rules'
18const RULES_DIR = '.claude/jev-rules'
19const MAP_DIR = '.claude/jev-map'
20const MAX_DEPTH = 8
21const POLL_MS = 700
22const LIST_MS = 2000
23const FRAME_MS = 250
24const FLASH_MS = 3000
25const GREEN = 'rgb(95,215,95)'
26
27type Row = ReturnType<typeof treeRows>[number]
28type Score = { p: number; via: string; file?: string }
29type Viewport = { columns?: number; isFullscreen?: boolean }
30type State = {
31 started: boolean
32 recordFile: string
33 rows: Row[]
34 picked: Set<string>
35 scores: Record<string, Score>
36 flashUntil: Map<string, number>
37 now: number
38 frame: number
39 isOpen: boolean
40 closedByPerson: boolean
41 viewport: Viewport | undefined
42 listedAt: number
43 isPolling: boolean
44}
45
46async function listNames($: EngineInterface, dir: string, prefix: string, depth: number, out: string[]): Promise<void> {
47 if (depth > MAX_DEPTH || !(await $.fs.exists(dir))) return
48 for (const entry of await $.fs.list(dir)) {
49 if (entry.name.startsWith('.')) continue
50 const path = `${dir}/${entry.name}`
51 if (entry.kind === 'dir' || (entry.isLink && !entry.name.endsWith('.md'))) {
52 await listNames($, path, `${prefix}${entry.name}/`, depth + 1, out).catch(() => undefined)
53 } else if (entry.name.endsWith('.md') && !(dir === MAP_DIR && entry.name === 'INDEX.md')) {
54 out.push(`${prefix}${entry.name.slice(0, -3)}`)
55 }
56 }
57}
58
59async function loadRows($: EngineInterface): Promise<Row[]> {
60 const rules: string[] = []
61 const map: string[] = []
62 await listNames($, RULES_DIR, '', 1, rules)
63 await listNames($, MAP_DIR, '', 1, map)
64 return treeRows([...rules.map(name => ({ kind: 'rule', name })), ...map.map(name => ({ kind: 'map', name }))])
65}
66
67async function recordPath($: EngineInterface): Promise<string> {
68 const tmp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? '/tmp'
69 return `${tmp.replace(/[\\/]+$/, '')}/jev-rules/${await $.session.id()}.json`
70}
71
72async function poll($: EngineInterface, state: State, setting: string): Promise<void> {
73 if (state.isPolling) return
74 state.isPolling = true
75 try {
76 const now = await $.clock.now()
77 let text: string | null = null
78 if (await $.fs.exists(state.recordFile)) text = await $.fs.read(state.recordFile)
79 const record = readRecord(text)
80 const arrived = newlyPicked(state.picked, record.picked)
81 const changed = arrived.length > 0 || record.picked.size !== state.picked.size || JSON.stringify(record.scores) !== JSON.stringify(state.scores)
82 for (const key of arrived) state.flashUntil.set(key, now + FLASH_MS)
83 state.picked = record.picked
84 state.scores = record.scores
85 if (state.isOpen && now - state.listedAt >= LIST_MS) {
86 const rows = await loadRows($)
87 state.listedAt = now
88 if (JSON.stringify(rows) !== JSON.stringify(state.rows)) {
89 state.rows = rows
90 $.ui.invalidate('ui.render')
91 }
92 }
93 if (arrived.length && shouldAutoOpen({ setting, viewport: state.viewport, closedByPerson: state.closedByPerson, isOpen: state.isOpen })) {
94 await openPane($, state)
95 }
96 if (changed) $.ui.invalidate('ui.render')
97 } catch (error) {
98 $.ui.log(`jev-rules pane: ${String(error)}`)
99 } finally {
100 state.isPolling = false
101 }
102}
103
104async function openPane($: EngineInterface, state: State): Promise<void> {
105 state.rows = await loadRows($)
106 state.listedAt = await $.clock.now()
107 await $.ui.open({ id: PANE_ID, title: TITLE })
108 state.isOpen = true
109 $.ui.invalidate('ui.render')
110}
111
112async function start($: EngineInterface, state: State, setting: string): Promise<void> {
113 if (state.started) return
114 state.started = true
115 state.recordFile = await recordPath($)
116 $.clock.every(POLL_MS, () => { void poll($, state, setting) })
117 $.clock.every(FRAME_MS, async () => {
118 if (!state.flashUntil.size) return
119 const now = await $.clock.now()
120 for (const [key, until] of state.flashUntil) if (until <= now) state.flashUntil.delete(key)
121 state.now = now
122 state.frame += 1
123 if (state.isOpen) $.ui.invalidate('ui.render')
124 })
125}
126
127export const register: Register = (on, options) => {
128 const setting = typeof options.pane_opens === 'string' ? options.pane_opens : 'on-first-pick'
129 const state: State = {
130 started: false, recordFile: '', rows: [], picked: new Set(), scores: {}, flashUntil: new Map(), now: 0, frame: 0,
131 isOpen: false, closedByPerson: false, viewport: undefined, listedAt: 0, isPolling: false,
132 }
133
134 on('session.start', async ($, e, next) => {
135 await $.command.register({ name: COMMAND, description: 'Show or hide the jev-rules pane: every rule, and the ones Jev picked', immediate: true })
136 const result = await next(e)
137 await start($, state, setting)
138 return result
139 })
140
141 on('command.run', { command: COMMAND }, async ($) => {
142 await start($, state, setting)
143 if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
144 await $.ui.close({ id: PANE_ID })
145 return { text: 'Rules pane hidden' }
146 }
147 state.closedByPerson = false
148 await poll($, state, setting)
149 await openPane($, state)
150 return { text: 'Rules pane shown' }
151 })
152
153 on('ui.close', async ($, e, next) => {
154 if (e.id !== PANE_ID) return next(e)
155 const result = await next(e)
156 state.isOpen = false
157 if (e.origin === 'person') state.closedByPerson = true
158 return result
159 })
160
161 // The band above the prompt is drawn in every session; its viewport says
162 // whether panes dock beside the transcript, which the pane needs to know
163 // before it opens by itself. It changes nothing in the band.
164 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
165 if (e.viewport) state.viewport = { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen }
166 return next(e)
167 })
168
169 on('ui.render', { component: 'Pane' }, ($, e, next) => {
170 if (e.requestId !== PANE_ID) return next(e)
171 const { Box, Text } = $.ui.resolve(e)
172 const columns = Math.max(20, e.props.bodyColumns ?? 40)
173 if (!state.rows.some(r => !r.isFolder)) {
174 return (
175 <Box flexDirection="column">
176 <Text bold>No rules yet</Text>
177 <Text dimColor>{`Add Markdown files to ${RULES_DIR}/ and they appear here.`}</Text>
178 </Box>
179 )
180 }
181 return (
182 <Box flexDirection="column">
183 <Text bold>{headline(state.rows, state.picked)}</Text>
184 <Text dimColor>green: given to Claude this session</Text>
185 <Text>{' '}</Text>
186 {state.rows.map(row => {
187 if (row.isFolder) return <Text key={row.key} dimColor wrap="truncate-end">{`${' '.repeat(row.depth)}${row.label}`}</Text>
188 const isPicked = state.picked.has(row.key)
189 const score = scoreText(state.scores[row.key])
190 const line = fitRow({ depth: row.depth, label: row.label, mark: isPicked ? '✓' : '·', score, columns })
191 const flashing = (state.flashUntil.get(row.key) ?? 0) > state.now && state.frame % 2 === 0
192 if (flashing) return <Text key={row.key} bold color="black" backgroundColor={GREEN} wrap="truncate-end">{line}</Text>
193 if (isPicked) return <Text key={row.key} bold color={GREEN} wrap="truncate-end">{line}</Text>
194 return <Text key={row.key} dimColor wrap="truncate-end">{line}</Text>
195 })}
196 </Box>
197 )
198 })
199}
200
201hooks/lib/pane-model.mjs 103 lines1// What the rules pane shows, as plain data: the tree of rule and map files,
2// which of them Claude has been given this session, and Jev's latest scores.
3// No file or engine access here, so node --test covers it; hooks/pane.tsx
4// reads the files and draws what these functions return.
5
6/** Keys match the session record: "rule:<name>" and "map:<name>". */
7export const keyOf = (kind, name) => `${kind}:${name}`;
8
9/**
10 * The rows of the tree, folders first within each level, then files, by name.
11 *
12 * @param {{kind: "rule"|"map", name: string}[]} items names are relative paths
13 * without ".md", such as "frontend/react"
14 * @returns {{key: string, label: string, depth: number, isFolder: boolean, kind: string}[]}
15 */
16export function treeRows(items) {
17 const rows = [];
18 for (const kind of ["rule", "map"]) {
19 const names = items.filter((i) => i.kind === kind).map((i) => i.name);
20 if (kind === "map" && !names.length) continue;
21 rows.push({ key: `head:${kind}`, label: kind === "rule" ? "rules" : "map", depth: 0, isFolder: true, kind });
22 walk(names, "", 1, kind, rows);
23 }
24 return rows;
25}
26
27function walk(names, prefix, depth, kind, rows) {
28 const here = names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length));
29 const folders = [...new Set(here.filter((n) => n.includes("/")).map((n) => n.split("/")[0]))].sort();
30 const files = here.filter((n) => !n.includes("/")).sort();
31 for (const folder of folders) {
32 rows.push({ key: `folder:${kind}:${prefix}${folder}`, label: `${folder}/`, depth, isFolder: true, kind });
33 walk(names, `${prefix}${folder}/`, depth + 1, kind, rows);
34 }
35 for (const file of files) rows.push({ key: keyOf(kind, `${prefix}${file}`), label: file, depth, isFolder: false, kind });
36}
37
38/**
39 * Reads the session record the hooks write. Picked means given to Claude this
40 * session: the delivered map, plus this turn's rules (which is all there is
41 * under JEV_RULES_REPEAT=1). Anything unreadable is an empty record.
42 *
43 * @param {string|null|undefined} text the record's JSON
44 * @returns {{picked: Set<string>, scores: Record<string, {p: number, via: string, file?: string}>}}
45 */
46export function readRecord(text) {
47 let raw = null;
48 try {
49 raw = text ? JSON.parse(text) : null;
50 } catch {
51 raw = null;
52 }
53 const picked = new Set();
54 const scores = {};
55 if (!raw || typeof raw !== "object") return { picked, scores };
56 if (raw.delivered && typeof raw.delivered === "object") for (const key of Object.keys(raw.delivered)) picked.add(key);
57 if (Array.isArray(raw.injected)) for (const name of raw.injected) if (typeof name === "string") picked.add(keyOf("rule", name));
58 if (raw.scores && typeof raw.scores === "object") {
59 for (const [key, s] of Object.entries(raw.scores)) {
60 if (s && typeof s.p === "number" && s.p >= 0 && s.p <= 1) scores[key] = s;
61 }
62 }
63 return { picked, scores };
64}
65
66/** The keys in `next` that were not in `before`: what just arrived, to flash. */
67export function newlyPicked(before, next) {
68 return [...next].filter((key) => !before.has(key));
69}
70
71/** "Jev picked 2 of 15", counting files only. */
72export function headline(rows, picked) {
73 const files = rows.filter((r) => !r.isFolder);
74 const got = files.filter((r) => picked.has(r.key)).length;
75 return `Jev picked ${got} of ${files.length}`;
76}
77
78/** A score as the pane prints it: "0.97", or "" when Jev has not judged it. */
79export const scoreText = (score) => (score ? score.p.toFixed(2) : "");
80
81/**
82 * Whether the pane opens by itself on a pick: only when the setting allows it,
83 * the layout docks panes beside the transcript, the window is wide enough, and
84 * the person has not closed it this session.
85 */
86export function shouldAutoOpen({ setting, viewport, closedByPerson, isOpen, minColumns = 144 }) {
87 if (setting !== "on-first-pick" || closedByPerson || isOpen) return false;
88 return viewport?.isFullscreen === true && (viewport.columns ?? 0) >= minColumns;
89}
90
91/**
92 * One row's text, fitted to the pane: indent, mark, name, and the score at the
93 * right edge. A name too long for the room is cut with an ellipsis.
94 */
95export function fitRow({ depth, label, mark, score, columns }) {
96 const left = `${" ".repeat(depth)}${mark} `;
97 const room = Math.max(4, columns - left.length - (score ? score.length + 1 : 0));
98 const name = label.length > room ? `${label.slice(0, room - 1)}…` : label;
99 if (!score) return `${left}${name}`;
100 const gap = Math.max(1, columns - left.length - name.length - score.length);
101 return `${left}${name}${" ".repeat(gap)}${score}`;
102}
103