Keeps Judy the Judge's rules automatic: no court code or Keychain secret in files, commits or the transcript, no bare wrangler deploy for Judy, and Jev only…

Claude Code mods for Judy the Judge, the LLM judge behind Gavel & Gown (Organized AI).
Live Board: https://judy-the-judge.jordan-691.workers.dev/hq
| Mod | What it does |
|---|---|
judy-board | Judy's Live Board in a pane beside the chat. It adds a status line with team count, nightly eval gates and the MLflow range, and toasts when a team files, a trial ruling lands or an audit finishes or fails. |
judy-guard | Judy's project rules, checked on every tool call instead of remembered: the court code and Keychain secrets stay out of files, commits and the transcript; Judy deploys through its gate; Jev is called only through jev-gateway. |
At the prompt of a Claude Code terminal session:
/plugin install judy-board --marketplace Organized-AI/judy-mods
/plugin install judy-guard --marketplace Organized-AI/judy-mods
Answer y to add the marketplace, then pick a scope (user scope is first; Enter takes it). Each mod is active straight away.
From a shell:
claude plugin install judy-board --marketplace Organized-AI/judy-mods
claude plugin install judy-guard --marketplace Organized-AI/judy-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Organized-AI/judy-mods, then Add plugin.
If this repository is private, it installs only for people who can read it on GitHub and whose machine already holds a git credential for github.com (an SSH key, or gh auth login then gh auth setup-git). The install never prompts for one.
/judy-board opens the Live Board pane: teams ranked by trial score, then practice score, with each team's ruling, practice standing and any audit error, plus a Refresh button./judy-team owner/repo (or a GitHub URL) puts one team's card in the chat: trial ruling and score, practice score and standing with the change since the last audit, and the link to its team card.⚖ Judy · 12 teams · 1 running · evals 13/13 · range up and refreshes every minute. On a refresh, up to three toasts announce what changed.It reads only public endpoints: Judy's /api/hq and the event guide's /judy/api/status. It needs no key or court code.
| Rule | Refuses | Allowed route | |
|---|---|---|---|
| 1. The court code stays in the chat | Any command or file write whose text contains today's court code, in any case | Scripts read it from JUDY_EVENT_CODE or the Keychain | |
| 2. No secret printed into the transcript | security find-generic-password … -w with its output neither piped, redirected nor captured; cat/head/tail/less of ~/.config/judy/{aig-token,event-code,openrouter-key} or .dev.vars | `… -w \ | wrangler secret put NAME, or "$(security find-generic-password -s NAME -w)"` |
| 3. Judy deploys through its gate | A bare wrangler … deploy in judy-the-judge (as the working directory, or after cd …judy-the-judge &&) | npm run deploy (typecheck, tests, the eval gate, D1 migrations, then wrangler), or --dry-run | |
| 4. Jev only through jev-gateway | api.typesafe.ai, TYPESAFE_API_KEY, typesafe/jev or a typesafe:/ model in a command outside jev-gateway, or in a code file written outside it | POST /v1/systemone on jev-gateway with JEV_GATEWAY_TOKEN. Docs (.md, .mdx, .txt) may name the rule. |
A refused call reaches the model as an error naming the rule, so it can take the allowed route, and a toast shows which rule fired.
The court code. At session start the guard reads it from the JUDY_EVENT_CODE environment variable or, on a Mac, from the Keychain item JUDY_EVENT_CODE (security add-generic-password -s JUDY_EVENT_CODE -a judy -w, which prompts for the value). It keeps the code in memory only, never in a file or $.state. With no code available, rule 1 is off and the other three still apply.
If a check fails, the guard lets the call through and says so on the status line. A broken guard must not stop all work.
claude --plugin-dir ./judy-board # run a mod from its folder; edits hot-reload
claude plugin validate ./judy-guard # manifest, hooks module and what the engine would refuse
claude plugin test ./judy-guard # 16 cases, one per rule edge
claude plugin test ./judy-board # status line, pane on every surface, /judy-team
hooks/register.ts 127 lines1// The project rules of Judy the Judge (Gavel & Gown, Organized AI), enforced on every tool call instead of remembered:
2//
3// 1. The court code never goes into a file, a commit or a log. It is read once per session from JUDY_EVENT_CODE, or
4// on a Mac from the Keychain item JUDY_EVENT_CODE, and kept in memory only.
5// 2. A Keychain secret is never printed into the transcript: `security find-generic-password … -w` must be piped
6// into something or captured with $(…), and the secret files (~/.config/judy/*, .dev.vars) are never cat'ed.
7// 3. Judy deploys with `npm run deploy` (typecheck, tests, the eval gate, D1 migrations, then wrangler), never a
8// bare `wrangler deploy` from judy-the-judge.
9// 4. Jev goes through jev-gateway only: no call to api.typesafe.ai, no TYPESAFE_API_KEY, no `typesafe/jev` or
10// `typesafe:/` model outside the jev-gateway repo.
11//
12// A refused call reaches the model as an error naming the rule, so it can take the allowed route. If the guard
13// itself fails, it lets the call through and says so: a broken guard must not stop all work.
14import type { Register } from 'claude-code'
15
16type Call = { tool: string; [k: string]: unknown }
17
18const RESERVED = new Set(['tool', 'tool_use_id', 'consent'])
19
20/** Every string the tool would act on, wherever its input keeps it (built-ins and MCP tools alike). */
21function strings(v: unknown, out: string[] = []): string[] {
22 if (typeof v === 'string') out.push(v)
23 else if (Array.isArray(v)) for (const x of v) strings(x, out)
24 else if (v && typeof v === 'object') for (const x of Object.values(v)) strings(x, out)
25 return out
26}
27
28function textOf(e: Call): string {
29 return Object.entries(e)
30 .filter(([k]) => !RESERVED.has(k))
31 .flatMap(([, v]) => strings(v))
32 .join('\n')
33}
34
35/** A shell command, from Bash, a remote shell or a process-running MCP tool; '' for anything else. */
36function commandOf(e: Call): string {
37 for (const k of ['command', 'command_line', 'cmd', 'script']) if (typeof e[k] === 'string') return e[k] as string
38 if (Array.isArray(e.argv)) return (e.argv as unknown[]).map(String).join(' ')
39 return ''
40}
41
42/** A file being written, from Write, Edit, MultiEdit or NotebookEdit; '' for anything else. */
43function writePathOf(e: Call): string {
44 if (!['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(e.tool)) return ''
45 return String(e.file_path ?? e.notebook_path ?? '')
46}
47
48const cwdOf = (e: Call) => String(e.cwd ?? '')
49const inRepo = (repo: string, cmd: string, cwd: string) =>
50 new RegExp(`(^|/)${repo}/?$`).test(cwd) || new RegExp(`\\bcd\\s+["']?[^;&|"'\\n]*${repo}/?["']?\\s*(&&|;|\\n)`).test(cmd)
51
52/** Rule 2: a secret read whose output is neither piped, redirected nor captured with $(…) lands in the transcript. */
53function printsKeychainSecret(cmd: string): boolean {
54 const reads = [
55 /security\s+find-generic-password\b[^;&|\n)]*\s-w\b/g,
56 /\b(?:cat|less|more|head|tail|bat)\s+[^;&|\n)]*(?:\.config\/judy\/(?:aig-token|event-code|openrouter-key)|\.dev\.vars\b)/g,
57 ]
58 for (const re of reads) {
59 for (let m = re.exec(cmd); m; m = re.exec(cmd)) {
60 const before = cmd.slice(0, m.index)
61 const captured = (before.match(/\$\(/g) ?? []).length > (before.match(/\)/g) ?? []).length
62 const rest = cmd.slice(m.index + m[0].length).split(/;|&&|\n/)[0] ?? ''
63 if (!captured && !rest.includes('|') && !rest.includes('>')) return true
64 }
65 }
66 return false
67}
68
69const TYPESAFE = /api\.typesafe\.ai|TYPESAFE_API_KEY|typesafe\/jev|typesafe:\//
70const DOCS = /\.(md|mdx|txt)$/i
71
72export const register: Register = on => {
73 let code = ''
74
75 on('session.start', async ($, e, next) => {
76 code = ((await $.env.get('JUDY_EVENT_CODE')) ?? '').trim()
77 if (!code) {
78 try {
79 const r = await $.process.run(['security', 'find-generic-password', '-s', 'JUDY_EVENT_CODE', '-w'], { timeoutMs: 5000 })
80 if (r.exitCode === 0) code = r.stdout.trim()
81 } catch {
82 // Not a Mac, or no Keychain here: rule 1 waits for JUDY_EVENT_CODE.
83 }
84 }
85 return next(e)
86 })
87
88 on('tool.call', async ($, e, next) => {
89 const call = e as unknown as Call
90 const cmd = commandOf(call)
91 const path = writePathOf(call)
92 const deny = (rule: string) => {
93 $.ui.toast(`judy-guard: ${rule.split('.')[0]}`)
94 return { deny: `judy-guard: ${rule}` }
95 }
96
97 // 1. The court code stays in the chat.
98 if (code.length >= 6 && (cmd || path) && textOf(call).toUpperCase().includes(code.toUpperCase())) {
99 return deny("That would put the court code into a file, a commit or a log. It goes in the chat only; scripts read it from JUDY_EVENT_CODE or the Keychain (security find-generic-password -s JUDY_EVENT_CODE -w, piped or captured).")
100 }
101
102 if (cmd) {
103 // 2. No Keychain secret printed into the transcript.
104 if (printsKeychainSecret(cmd)) {
105 return deny('That prints a secret into the transcript. Pipe it (… -w | wrangler secret put NAME) or capture it ("$(security find-generic-password -s NAME -w)"), and never cat ~/.config/judy/* or .dev.vars.')
106 }
107 // 3. Judy deploys through its gate.
108 if (/\bwrangler\b[^;&|\n]*\bdeploy\b/.test(cmd) && !/--dry-run/.test(cmd) && !/npm run deploy/.test(cmd) && inRepo('judy-the-judge', cmd, cwdOf(call))) {
109 return deny('Deploy Judy with npm run deploy (typecheck, tests, the eval gate, D1 migrations, then wrangler deploy), never a bare wrangler deploy.')
110 }
111 // 4. Jev only through jev-gateway.
112 if (TYPESAFE.test(cmd) && !inRepo('jev-gateway', cmd, cwdOf(call))) {
113 return deny('Jev goes through jev-gateway only (POST /v1/systemone with JEV_GATEWAY_TOKEN), never TypeSafe directly or with a TypeSafe key.')
114 }
115 }
116
117 if (path && !DOCS.test(path) && !/(^|\/)jev-gateway\//.test(path) && TYPESAFE.test(textOf(call))) {
118 return deny(`${path}: Jev goes through jev-gateway only (POST /v1/systemone with JEV_GATEWAY_TOKEN), never TypeSafe directly, a typesafe:/ model or a TypeSafe key.`)
119 }
120
121 return next(e)
122 }).catch(($, e, next) => {
123 $.ui.status('judy-guard: a check failed; the call went through unchecked')
124 return next(e)
125 })
126}
127