SLOPSHOPPER

zebra-mod

Turns Claude Code into a rare-disease research workstation: phenotype-driven diagnosis, variant interpretation (ACMG), sequence-to-function models…

newpanebandspinnerrowsguard
v0.3.3MITupdated 2026-10-08zwbao/zebra-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zebra-mod
│ ┃ zebra-board ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No active case. /zebra new <dir> [title] │ zebra-mod │ │ ⏺ Read(src/auth.ts) │ 🦓 zebra-mod is ready: just describe │ │ ⎿ Read 6 lines │ symptoms or test results. Type /zebra demo │ │ ⏺ Update(src/auth.ts) │ to try a demo case, /zebra for everything │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /zebra │ ⎿ zebra-mod: 🦓 **zebra-mod** · rare-disease research workstation · │ ⎿ zebra-mod: │ ⎿ zebra-mod: **How to use it: just ask.** Describe symptoms, paste │ ⎿ zebra-mod: - My daughter has had seizures with fever since 6 mon │ ⎿ zebra-mod: - What could this be: small head, hearing loss, walke │ ⎿ zebra-mod: - Which treatments are approved for Dravet syndrome, │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · zebra-board
No active case. /zebra new <dir> [title]
README

🦓 zebra-mod

A Claude Code mod that turns Claude Code into a rare-disease research workstation — for patients and families on a diagnostic odyssey, for clinicians, and for researchers.

"When you hear hoofbeats, think horses, not zebras." Medicine is taught to expect the common. People with rare diseases call themselves zebras because their answers lie off the beaten path. zebra-mod is built to look there — and to show its evidence for every step.

中文说明 → README.zh.md


What it does

JobHow zebra-mod does it
"What could this be?" — phenotype-driven differential diagnosisRecords → HPO profile (verified terms, present/excluded, onset, source page) → three independent rankings (offline Resnik over HPO annotations, Monarch semantic similarity, PubCaseFinder) kept separate, agreement as signal → discriminating features → which test finds each candidate (exome, genome, CMA, repeat assays, methylation, mtDNA, metabolic)
"What does this variant mean?" — ACMG/AMP interpretationVEP (MANE, AlphaMissense, REVEL, CADD) + SpliceAI at ±4,999 nt + gnomAD (grpmax, faf95; mtDNA heteroplasmy counts) + ClinVar (n of 4 stars) + ClinGen validity and dosage + MaveDB scores under their own calibration + LitVar (other alleles at the position excluded) + Chinese-cohort frequencies → data-driven codes with ClinGen-calibrated thresholds, PVS1 and PS1/PM5 inputs → your judged codes → points computed by code (Tavtigian 2020) and the 2015 combining rules side by side; GRCh37 input mapped to GRCh38
Sequence-to-function (S2F) — splicing, non-coding, regulatorySpliceAI/Pangolin (Broad lookup), AlphaGenome, Evo 2 and GPN-MSA through the s2f-penguin s2f CLI; axis by axis with claim ceilings, combined by agreement, never averaged; mechanism → how to confirm with RNA, in a tissue GTEx shows the gene is expressed in; a splice-switching antisense screen (aso_screen: the aberrant event checked in the patient sequence, candidate target windows, published N-of-1 precedents)
The other report forms — microarray/CNV, exon-level deletions, SMN1 copy number, repeat expansionscnv_interpret: genes covered, ClinGen dosage sensitivity, ACMG/ClinGen CNV evidence inputs (never a classification), and for an out-of-frame exon deletion the frame arithmetic that says which exon skipping would restore it
Exome/genome reanalysis on your own machinezebra qc: sex check, KING kinship (swaps, non-paternity), runs of homozygosity, Mendelian errors and uniparental disomy, mosaic de novo calls. VCF triage: quality, inheritance models (de novo, homozygous, compound het, X-hemizygous), PED input, phenotype-gene restriction, SpliceAI on the top splice/non-coding candidates; a whole exome through a MyVariant frequency prefilter. The VCF file stays on the computer; variant positions go to the annotation services — by default only the filtered candidates, with the prefilter every variant (the mod asks first)
Genotype → therapy (G2T)Mechanism first (LoF / GoF / DN / splicing / repeat) → approved and investigational drugs (Open Targets / ChEMBL), access: FDA and EMA status from the agencies' own records, approval in China from official NMPA/CDE documents, China's 2025 reimbursement list with its restriction text, trials with sites in China; literature, N-of-1 screens (antisense, gene replacement, base editing feasibility) → leads tiered A–E with a mechanism-fit check
Classical statisticsCosegregation LR → PP1, maximum credible allele frequency (BS1), carrier frequency and genetic prevalence, recurrence risk (incl. Bayesian X-linked), Fisher/burden, de novo enrichment, Kaplan–Meier natural history + log-rank, N-of-1 trial design and analysis
LiteratureEurope PMC, PubTator3, LitVar2 — PMIDs only as returned, findings quoted from the paper
For familiesPlain-language explanations (Chinese by default for Chinese speakers), VUS explained honestly, visit preparation, recurrence and cascade testing, patient organisations, China's national rare disease lists and the collaboration-network hospitals by province
Over months and yearscase_recheck asks the case's questions again — ClinVar class and stars of each variant, ClinGen validity of its genes, recruiting trials and new papers for each open hypothesis — and reports what changed since the last check, into the case timeline
ReportsClinician summary, family letter and visit-preparation sheet, every claim cited, an independent adversarial audit before it is final, then exported to Word and PDF with Chinese typography — so a clinician or volunteer can run zebra-mod for a family and hand them the files

Principles (enforced, not just written)

  1. Evidence, not recall. Every answer cites a ledger id, PMID or database record retrieved in the session. Each case keeps an append-only evidence ledger (E1, E2, …) of every source used.
  2. Code scores, the model interprets. Rankings, ACMG points and statistics are computed by the zebra engine; the model never writes or adjusts a number.
  3. Clarify, never invent. Assembly, transcript, zygosity, inheritance, sex — missing and material means ask, or conclude conditionally.
  4. Research-grade, not clinical. No diagnosis delivered as fact, no dosing; next steps are questions for the care team.
  5. Privacy, stated exactly. Case files are written only on your machine, and zebra's own database queries carry biology (HPO ids, genes, variants, disease names), never a name, a date of birth or a record number: a privacy gate in the mod refuses an outgoing call that carries the case's registered identifiers or an ID-number/phone/email pattern, asks before a file from the case folder or a raw genome file (VCF/BAM/CRAM/FASTQ) leaves the machine, and closes (refusing outgoing calls) if it cannot read the case's identifier list. What the gate cannot do: every record you ask Claude to read — every PDF, photo and report — is sent to the model provider as part of the conversation, like any other file you open in Claude Code; the gate inspects tool calls, not the conversation. Register identifiers early (the model does it with case_update → identifiers when records name the patient), and redact before sharing if that matters to you. Exports to Word/PDF refuse a report that still holds a registered identifier or an ID number. Matching is best effort over encodings and spellings, not a guarantee.

How good is it

Measured, not claimed — docs/BENCHMARK.md has the method, the data digests and the confidence intervals.

  • Phenotype ranking on GA4GH phenopacket-store 0.1.27 (10,374 published cases, 780 diseases), split by disease into development and held-out halves. On held-out cases whose own paper is not one of the HPO annotation sources — the fair test for a new patient — the correct disease is in the local top 10 for 28% (0.1.0: 15%) and the causal gene for 40%. Across all held-out cases it is 70%, an upper bound: for most published cases the disease's annotations were curated from that very paper. On a 100-case web sample, local + Monarch + PubCaseFinder together put the correct disease in the top 10 for 75%. A ranking is a list of hypotheses to test.
  • One honest cost: the PRD's own example (febrile, focal and tonic-clonic seizures, developmental delay; hypotonia excluded) now ranks Dravet syndrome 16th locally (6th in 0.1.0), because excluded terms are flagged rather than scored — the choice that measured better on the held-out half.
  • evals/ holds 10 end-to-end cases for claude plugin eval (a Chinese parent's records, urgent symptoms first, a VUS, a CNV, mtDNA, therapy and trials, privacy, an out-of-scope request).

What the mod adds to Claude Code

  • 21 tools the model calls directly (mcp__zebra-mod__*): case_status, case_update, case_recheck, hpo_search, phenotype_rank, gene_card, variant_card, disease_card, acmg, cnv_interpret, s2f_predict, therapy_landscape, trials_search, literature_search, rare_stats, edit_check, china_rare, access, expression, aso_screen, report_export. Every call goes through your permission rules and the privacy gate; read-only lookups skip the prompt unless a rule of yours says otherwise, and case writes follow your permission mode (with an "allow for this session" choice).
  • A research doctrine in the system prompt (the rules above), with the active case.
  • You can see it at work (all additive; unrelated work draws as before): its tool calls get their own rows (🦓 Variant card NM_001165963.4:c.2134C>T, then ⎿ 15 evidence rows (E39–E53) · from Ensembl VEP, gnomAD, ClinVar, LitVar2 · 3 cached) instead of a JSON envelope, and a case_update row never shows the identifiers it registers; the spinner names the databases being queried; a small galloping zebra above the prompt while a query runs, a shield there after the privacy gate stops a call; a 🦓 label in the footer (with the open case's title); and one line at the end of a turn that used it — calls, databases, evidence rows, calls stopped. Chinese or English, from the case's language or your prompts. Option interface: full (default), quiet (no animation, no spinner text) or off.
  • A case board pane (/zebra board) and the case in the footer label (a pinned status line when interface is off): phenotypes, variants and their research class, hypotheses, therapy leads, open questions, evidence count — live as the case changes.
  • /zebra command: with no argument, the guide (how to use it, what it does); demo (the synthetic demo case, first question ready), new <dir> [title], case <dir>, board, ledger, doctor, close.
  • 12 skills (/zebra-mod:zebra-start routes): zebra-safety (urgent red flags and disease-specific drug, anaesthesia and procedure hazards), zebra-intake, zebra-diagnose, zebra-variant, zebra-reanalysis, zebra-s2f, zebra-therapy, zebra-stats, zebra-literature, zebra-family, zebra-report.
  • 6 subagents: phenotype-curator, variant-curator, s2f-analyst, therapy-scout, literature-scout, evidence-auditor.
  • The zebra CLI (Python standard library only, Python ≥ 3.9): the single implementation behind every tool, usable from any shell, notebook or other agent.

Install

Requirements: Claude Code ≥ 2.1.289 (function-hook mods) and Python ≥ 3.9. No sudo, no pip installs.

One sentence. In Claude Code, say:

Install https://github.com/zwbao/zebra-mod

Claude Code reads INSTALL.md and does the rest: checks versions and Python, registers and installs the plugin, downloads the HPO release (~80 MB), and runs zebra doctor. Then start a new session (or type /reload-plugins) and run /zebra-mod:zebra-start.

One command (the same steps):

curl -fsSL https://raw.githubusercontent.com/zwbao/zebra-mod/main/install.sh | sh
# while the repository is private, clone it first:
git clone https://github.com/zwbao/zebra-mod ~/zebra-mod && ~/zebra-mod/install.sh

By hand:

claude plugin marketplace add zwbao/zebra-mod
claude plugin install zebra-mod@zebra-mod
python3 <installPath>/bin/zebra hpo fetch     # optional: offline phenotype ranking, instant HPO search
# one session only, no install: claude --plugin-dir ~/zebra-mod

Optional heavy S2F models: install the s2f CLI from s2f-penguin (uv tool install "git+https://github.com/zwbao/s2f-penguin"), and set ALPHAGENOME_API_KEY (AlphaGenome: non-commercial, not for clinical decisions) and/or NVCF_RUN_KEY (Evo 2 on NVIDIA). Check everything with /zebra doctor.

Options (/config → zebra-mod, or pluginConfigs in settings): python (interpreter), doctrine (auto by default: the full rules while a case is open, otherwise a short section that only applies to rare-disease questions, so the rest of your work in Claude Code is untouched; or always | case | off), privacyGate (on by default; with no case open, an email or phone number in another tool's call is asked about rather than refused), interface (full by default: its own tool rows, the spinner text, the galloping zebra and the end-of-turn line; quiet without the animation and spinner text; off for Claude Code's own drawing only).

Quick start

Just ask. Describe symptoms, paste test results or a genetic report, or ask about a variant, a disease, a treatment or a trial, in English or Chinese: Claude calls zebra-mod's tools by itself. There are no commands to learn; /zebra shows what it does.

Try it first: /zebra demo opens a synthetic demo case (a clinic note and a genetic report, identifiers already registered; the installer creates it at ~/zebra-cases/demo-lily or demo-xiaoyu) and puts the first question in the prompt box. Press Enter and watch it read the records, rank a differential and interpret the variant.

With your own records:

/zebra new ~/cases/lily "Lily — seizures since 6 months"
# put reports, lab sheets, the genetic report in ~/cases/lily/records/
/zebra-mod:zebra-intake
/zebra-mod:zebra-diagnose
/zebra-mod:zebra-variant   NM_001165963.4:c.2134C>T
/zebra-mod:zebra-therapy
/zebra-mod:zebra-report    family letter in Chinese

Or just ask in your own words — "我女儿6个月开始发热抽搐,基因报告说SCN1A有个变异,这是什么意思?" — the router skill takes it from there.

Researchers can drive the engine directly:

zebra hpo rank HP:0002373 HP:0007359 HP:0002133 HP:0001263 --exclude HP:0001252
zebra phenotype rank --present HP:0002373 HP:0001263 --sources local,monarch,pubcasefinder
zebra variant NM_000492.4:c.1652G>A --json
zebra acmg suggest NM_001165963.4:c.2134C>T --inheritance AD
zebra acmg classify PVS1 PS2 PM2_Supporting
zebra s2f predict 7-117559590-ATCT-A --models spliceai,pangolin
zebra vcf triage trio.vcf.gz --proband P --mother M --father F --sex female --hpo-genes --case ~/cases/lily
zebra stats maxaf --prevalence 0.0000625 --allelic 0.05 --penetrance 0.9 --faf95 0.00012
zebra edit 1-12345678-T-C --assembly GRCh38

Layout

.claude-plugin/plugin.json      manifest (+ marketplace.json)
hooks/register.tsx              the mod: tools, doctrine, case board, /zebra, privacy gate
hooks/{tools,doctrine,privacy}.ts
types/index.d.ts                the mod's state contract
skills/<name>/SKILL.md          12 skills (vercel-labs/skills layout)
agents/*.md                     6 subagents
zebra/                          Python engine (stdlib only): sources/, commands/, acmg, stats, hpo_local, vcf, s2f, editing, case
bin/zebra                       CLI launcher
tests/                          pytest (offline + live-marked) and mod tests
docs/                           PRD, architecture, data sources and terms

Data sources and terms

zebra-mod queries public resources live and keeps their provenance; each keeps its own licence (see docs/DATA-SOURCES.md). Notably: AlphaGenome outputs are non-commercial and not for clinical decision-making; SpliceAI weights are CC BY-NC; OMIM content is not redistributed.

Not a medical device

zebra-mod produces research-grade analyses to help people ask better questions. It does not diagnose, prescribe, or replace a clinician or genetic counsellor.

Lineage

zebra-mod's sequence-to-function (S2F) layer builds on s2f-penguin: sequence-to-function models with run receipts, claim ceilings, and triangulation by logic rather than arithmetic. zebra-mod calls its s2f CLI for the heavy models.

The galloping zebra above the prompt is traced from Eadweard Muybridge's The Horse in Motion (1878, public domain): eleven positions of one stride, the rider removed, aligned on the back so the body stays level, drawn in braille (tools/zebra_sprite/build.py rebuilds it).

MIT licence © 2026 zwbao.

Source 7 files
hooks/register.tsx 906 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Ready } from '../types'
5import { DOCTRINE, DOCTRINE_BRIEF, renderDoctrine } from './doctrine'
6import { schemaArgs, TOOLS, toolArgv, type ToolDef } from './tools'
7import { registerUi } from './ui'
8import { guardInput, isOutboundShell, outboundText, sendsVariantList, shellWords, uploadedPaths, uploadsGenome } from './privacy'
9
10const PLUGIN = 'zebra-mod'
11const PANE = 'zebra-board'
12const TOOL_PREFIX = `mcp__${PLUGIN}__`
13const MAX_RESULT_CHARS = 60_000
14const MAX_ARTIFACT_SCAN = 200_000
15// The case summary is pinned as a status line only when the mod's own drawing is off: Claude Code
16// draws pinned lines as notices (with a warning sign), and ui.tsx carries the summary in the footer.
17// A case that cannot be read is still pinned: that one is a warning.
18let pinCaseStatus = false
19
20// Tools that never reach the network: they read and write the local case only.
21const LOCAL_TOOLS = new Set(['case_status', 'case_update', 'report_export'])
22// Tools whose answer is a lookup with no side effect, safe to run without asking.
23// tools that can carry text off the machine: what a crashed gate refuses instead of passing
24const OUTBOUND_HINT = /^(?:Bash|WebFetch|WebSearch|Artifact|SendMessage|Agent|mcp__)/
25const READ_ONLY_TOOLS = new Set([
26  'case_status', 'hpo_search', 'phenotype_rank', 'gene_card', 'variant_card', 'disease_card', 'acmg',
27  's2f_predict', 'therapy_landscape', 'trials_search', 'literature_search', 'rare_stats', 'edit_check', 'china_rare',
28  'cnv_interpret', 'access', 'expression', 'aso_screen',
29])
30
31const board = atom({ plugin: 'zebra-mod', key: 'board' } as const, null as Board | null)
32const casePath = atom({ plugin: 'zebra-mod', key: 'casePath' } as const, null as string | null)
33const guard = atom({ plugin: 'zebra-mod', key: 'guard' } as const, [] as string[])
34const ready = atom({ plugin: 'zebra-mod', key: 'ready' } as const, null as Ready | null)
35// zebra tools the person allowed for the rest of this session in zebra's own approval dialog
36const trusted = atom({ plugin: 'zebra-mod', key: 'trusted' } as const, [] as string[])
37// identifiers registered while no case was open: protected for this session, kept only in memory,
38// and added to the next case that is opened or created
39const sessionIds = atom({ plugin: 'zebra-mod', key: 'sessionIds' } as const, [] as string[])
40
41type Envelope = {
42  ok: boolean
43  result?: unknown
44  sources?: unknown[]
45  warnings?: string[]
46  ledger?: string[]
47  error?: { type: string; message: string }
48}
49
50export const register: Register = (on, options) => {
51  // first, so its hooks wrap the ones below: what the person sees of zebra-mod at work
52  registerUi(on, options)
53  pinCaseStatus = options.interface === 'off'
54  const python = String(options.python ?? 'python3')
55  const doctrineMode = String(options.doctrine ?? 'auto')
56  const privacyOn = options.privacyGate !== false
57
58  // ------------------------------------------------------------ session
59
60  on('session.start', async ($, e, next) => {
61    const started = await next(e)
62
63    // The tools first: a slow interpreter must not delay the first prompt.
64    for (const def of TOOLS) {
65      await $.tool.register({ name: def.name, description: def.description, inputSchema: def.inputSchema })
66    }
67    await $.command.register({
68      name: 'zebra',
69      description: 'zebra-mod: what it does and how to start — /zebra [demo|new <dir> [title]|case <dir>|board|ledger|doctor|close]',
70      argumentHint: '[demo|new <dir> [title]|case <dir>|board|ledger|doctor|close]',
71    })
72
73    // bin/ on PATH, and the interpreter the tools use, so `zebra` in Bash is the same CLI
74    const path = (await $.env.get('PATH')) ?? ''
75    const bin = `${$.plugin.root}/bin`
76    if (!path.split(':').includes(bin)) await $.env.set('PATH', `${bin}:${path}`)
77    await $.env.set('ZEBRA_PYTHON', python)
78
79    void (async () => {
80      const r = await checkCli($, python)
81      if (!r.version) {
82        $.ui.toast(r.python
83          ? `zebra-mod: ${python} could not run the zebra CLI — run /zebra doctor`
84          : `zebra-mod: ${python} not found; set the plugin's python option (needs Python 3.9+)`)
85      }
86
87      // A case is adopted only when this directory is one or lies inside one, or when it was
88      // the case last used in this directory. A case is never inherited from another project.
89      const held = await read($, casePath)
90      let active: string | null = held
91      if (active === null) {
92        const own = (await caseAt($, e.cwd)) ?? (await enclosingCase($, e.cwd))
93        if (own) active = own
94        else {
95          const remembered = await rememberedFor($, e.cwd)
96          if (remembered && (await caseAt($, remembered))) active = remembered
97          else if (remembered) {
98            $.ui.toast(`zebra-mod: the case remembered here (${remembered}) is gone; /zebra case <dir> to pick one`)
99          }
100        }
101      }
102      if (active) await setCase($, python, active, e.isInteractive)
103      else $.ui.status(undefined)
104
105      // keep the board in step with edits made outside the mod's tools (the CLI in Bash, an editor)
106      let lastSeen = ''
107      $.clock.every(4000, async () => {
108        const now = await read($, casePath)
109        if (!now) return
110        try {
111          const st = await $.fs.stat(`${now}/case.json`)
112          const led = await $.fs.stat(`${now}/evidence/ledger.jsonl`).catch(() => undefined)
113          const stamp = `${st.mtimeMs}:${led?.mtimeMs ?? 0}:${led?.size ?? 0}`
114          if (stamp !== lastSeen) {
115            lastSeen = stamp
116            await refreshBoard($, python)
117          }
118        } catch {
119          // the case folder moved or was deleted: leave the board as it was
120        }
121      })
122    })().catch(() => undefined) // background work: a failure here must not surface as an unhandled rejection
123
124    return started
125  })
126
127  // ------------------------------------------------------------ the doctrine
128
129  on('prompt.compose', async ($, e, next) => {
130    const composed = await next(e)
131    if (doctrineMode === 'off' || e.traits.includes('bare')) return composed
132    const active = await read($, casePath)
133    if (doctrineMode === 'case' && !active) return composed
134    // auto (the default): the full doctrine with a case open; otherwise a short section that
135    // applies only to rare-disease questions, so other work in the same Claude Code is untouched
136    const text = doctrineMode === 'auto' && !active ? DOCTRINE_BRIEF : renderDoctrine(DOCTRINE, active, await read($, board))
137    return {
138      sections: [...composed.sections, { id: 'zebra-mod:doctrine', text, scope: 'session' as const }],
139    }
140  })
141
142  // ------------------------------------------------------------ tools
143
144  on('tool.call', async ($, e, next) => {
145    if (!e.tool.startsWith(TOOL_PREFIX)) return next(e)
146    const def = TOOLS.find(t => `${TOOL_PREFIX}${t.name}` === e.tool)
147    if (!def) return next(e)
148    const input = e as unknown as Record<string, unknown>
149    // A tool this plugin registers is answered here, and nothing beneath this hook runs
150    // the engine's permission chain for it. So the decision is asked for explicitly:
151    // the person's rules, the session's mode and the privacy gate apply to zebra's own
152    // tools exactly as they do to any other tool.
153    const verdict = await $.tool.check({ tool: e.tool, input: schemaArgs(def, input) })
154    if (verdict.decision === 'deny') return { deny: verdict.reason ?? `${def.name} was refused` }
155    if (verdict.decision === 'ask' && !(await approved($, def, verdict))) {
156      return { deny: `${def.name} was not approved` }
157    }
158    if (def.name === 'case_update' && !(await read($, casePath))) {
159      const held = await holdForSession($, input)
160      if (held) return { result: held }
161    }
162    let argv: string[]
163    try {
164      argv = await toolArgv(def, input, await read($, casePath))
165    } catch (err) {
166      return { result: `zebra ${def.name}: ${String(err instanceof Error ? err.message : err)}` }
167    }
168    const got = await runZebra($, python, argv, def.timeoutMs ?? 120_000)
169    if (def.touchesCase) await refreshBoard($, python)
170    return { result: formatEnvelope(def, got) }
171  })
172
173  on('tool.describe', async ($, e, next) => {
174    if (!e.tool.startsWith(TOOL_PREFIX)) return next(e)
175    const def = TOOLS.find(t => `${TOOL_PREFIX}${t.name}` === e.tool)
176    if (!def) return next(e)
177    const described = await next(e)
178    return { ...described, isDeferred: def.deferred === true }
179  })
180
181  // ------------------------------------------------------------ permissions and privacy
182
183  on('tool.check', async ($, e, next) => {
184    const own = e.tool.startsWith(TOOL_PREFIX) ? TOOLS.find(t => `${TOOL_PREFIX}${t.name}` === e.tool) : undefined
185    const isLocalOwn = own !== undefined && LOCAL_TOOLS.has(own.name)
186    const command = e.tool === 'Bash' ? String((e.input as { command?: unknown })?.command ?? '') : ''
187    const isRemoteAgent = e.tool === 'Agent' && (e.input as { isolation?: unknown })?.isolation === 'remote'
188    const outbound =
189      (own !== undefined && !isLocalOwn) ||
190      e.tool === 'WebFetch' ||
191      e.tool === 'WebSearch' ||
192      e.tool === 'Artifact' ||
193      e.tool === 'SendMessage' ||
194      isRemoteAgent ||
195      (e.tool.startsWith('mcp__') && own === undefined) ||
196      (e.tool === 'Bash' && isOutboundShell(command))
197
198    if (privacyOn && outbound) {
199      const active = await read($, casePath)
200      const now = await identifiersNow($, active)
201      // a copy: the session list comes from engine state, which must not be mutated in place
202      const held = { isClosed: now.isClosed, ids: [...now.ids, ...(e.tool === 'Bash' ? await namedCaseIdentifiers($, command, active) : [])] }
203      if (held.isClosed) {
204        return {
205          decision: 'deny' as const,
206          reason: `zebra-mod privacy gate: ${active}/case.json cannot be read, so the protected identifiers are unknown and the gate is closed. Fix or re-create the case file, or /zebra close to work without one.`,
207        }
208      }
209      // Scan what actually leaves: a shell command's outbound segments without local paths
210      // or sample names; a published page's text below; anything else as given.
211      const scanned = e.tool === 'Bash' ? { command: outboundText(command) }
212        : e.tool === 'Artifact' ? artifactFields(e.input)
213        : own !== undefined ? schemaArgs(own, (e.input ?? {}) as Record<string, unknown>)
214        : e.input
215      const hit = guardInput(scanned, held.ids)
216      if (hit && !hit.startsWith('protected identifier') && !active && own === undefined) {
217        // No case is open, so this is not known to be patient work: an email or a phone number
218        // in an ordinary call (a PR body, an API request) is asked about, never refused outright.
219        return {
220          decision: 'ask' as const,
221          reason: `zebra-mod privacy gate: this call would send ${hit} off this machine. If it belongs to a patient, do not send it; if it is yours or public, confirm.`,
222        }
223      }
224      if (hit) {
225        return {
226          decision: 'deny' as const,
227          reason: `zebra-mod privacy gate: this call would send ${hit} off this machine. Query public databases with HPO ids, gene symbols, variants and disease names only.`,
228        }
229      }
230      // a file from the case folder about to be sent somewhere
231      const paths = e.tool === 'Bash' ? uploadedPaths(command) : artifactPaths(e.input)
232      if (paths.length > 0 && active) {
233        const inside = await firstInsideCase($, active, paths, (e.input as { root?: unknown })?.root)
234        if (inside) {
235          return {
236            decision: 'ask' as const,
237            reason: `zebra-mod: this would send ${inside}, a file inside the case folder, off this machine. Its contents are not checked for names or record numbers — confirm only if you have read it and trust the destination.`,
238          }
239        }
240      }
241      if (e.tool === 'Artifact') {
242        const scan = await artifactLeak($, e.input, held.ids)
243        if (scan.leak) {
244          return {
245            decision: 'deny' as const,
246            reason: `zebra-mod privacy gate: the page about to be published contains ${scan.leak}. Publishing puts it on the web.`,
247          }
248        }
249        if (scan.unread.length > 0) {
250          return {
251            decision: 'ask' as const,
252            reason: `zebra-mod: ${scan.unread.join(', ')} could not be read to check for names or record numbers before publishing — confirm only if you have checked it yourself.`,
253          }
254        }
255      }
256      if (e.tool === 'Bash' && uploadsGenome(command)) {
257        return {
258          decision: 'ask' as const,
259          reason: 'zebra-mod: this command may move raw genome data (VCF/BAM/CRAM/FASTQ) to another machine. A genome identifies a person and their relatives — confirm the destination is one you trust.',
260        }
261      }
262      if (e.tool === 'Bash' && sendsVariantList(command)) {
263        return {
264          decision: 'ask' as const,
265          reason: 'zebra-mod: --prefilter myvariant sends every quality-passing variant position in this VCF (often tens of thousands) to myvariant.info. Taken together they are the person\'s genome — confirm, or run triage without the prefilter (only the candidates that survive local filtering go out).',
266        }
267      }
268    }
269
270    // The engine's own decision stands; this only spares the person a prompt for a
271    // read-only lookup, and never overrides a deny, a rule or the mode's own answer.
272    const below = await next(e)
273    const writes = own?.name === 'cnv_interpret' && (e.input as { record?: unknown })?.record === true
274    if (own && !writes && below.decision === 'ask' && below.rule === undefined && READ_ONLY_TOOLS.has(own.name)) {
275      return { decision: 'allow' as const, reason: 'zebra-mod: read-only lookup in public databases and local case files' }
276    }
277    return below
278  }).catch(async ($, e, next) => {
279    if (OUTBOUND_HINT.test(e.tool)) {
280      return { decision: 'deny' as const, reason: `zebra-mod privacy gate could not check this call (${next.error.kind}); it was refused rather than let through unchecked` }
281    }
282    return next(e)
283  })
284
285  // ------------------------------------------------------------ /zebra
286
287  on('command.run', { command: 'zebra' }, async ($, e) => {
288    const words = shellWords(e.args.trim())
289    const verb = words[0] ?? ''
290    const rest = words.slice(1)
291    if (verb === '' || verb === 'help' || verb === 'zh' || verb === 'en') {
292      if ((await read($, ready)) === null) await checkCli($, python)
293      const r = await read($, ready)
294      const active = await read($, casePath)
295      if (active) await $.ui.open({ id: PANE, title: 'zebra · case board' })
296      return { text: helpText(r, active, await guideIsZh($, verb === 'zh' || verb === 'en' ? verb : rest[0])) }
297    }
298    if (verb === 'demo') {
299      // the bundled synthetic case: created (or reopened), made active, the first question put in the prompt box
300      const zh = await guideIsZh($, rest[0])
301      const dirArg = rest.find(w => w !== 'zh' && w !== 'en')
302      const made = await runZebra($, python, [
303        'case', 'demo', ...(dirArg ? [await absolute($, dirArg)] : []), '--lang', zh ? 'zh' : 'en',
304      ])
305      if (!made.ok) return { text: `${zh ? '示例病例没能准备好:' : 'Could not prepare the demo case: '}${made.error?.message ?? 'unknown error'}` }
306      const demo = made.result as DemoResult
307      await setCase($, python, demo.path, e.origin.kind === 'composer')
308      await $.ui.open({ id: PANE, title: 'zebra · case board' })
309      const filled = await $.prompt.fill({ text: demo.first_prompt, mode: 'replace' })
310      return { text: demoText(demo, zh, filled.isFilled) }
311    }
312    if (verb === 'board') {
313      const active = await read($, casePath)
314      if (!active) return { text: 'No active case. /zebra new <dir> [title] creates one; /zebra case <dir> opens one.' }
315      await refreshBoard($, python)
316      const opened = await $.ui.open({ id: PANE, title: 'zebra · case board' })
317      return { text: opened.isPlaced ? 'Case board opened.' : 'Case board waits for a wider terminal.' }
318    }
319    if (verb === 'case') {
320      const arg = rest.join(' ')
321      if (!arg) return { text: `Active case: ${(await read($, casePath)) ?? 'none'}` }
322      const dir = await absolute($, rest.length === 1 ? (rest[0] as string) : arg)
323      if (!(await $.fs.exists(`${dir}/case.json`))) return { text: `No case.json in ${dir}. /zebra new ${arg} creates one.` }
324      await setCase($, python, dir, e.origin.kind === 'composer')
325      await $.ui.open({ id: PANE, title: 'zebra · case board' })
326      return { text: `Active case: ${dir}` }
327    }
328    if (verb === 'new') {
329      const dirArg = rest[0]
330      const title = rest.slice(1).join(' ')
331      if (!dirArg) return { text: 'Usage: /zebra new <dir> [title]   (quote a path or title that contains spaces)' }
332      const dir = await absolute($, dirArg)
333      const made = await runZebra($, python, ['case', 'init', dir, ...(title ? ['--title', title] : [])])
334      if (!made.ok) return { text: `Could not create the case: ${made.error?.message ?? 'unknown error'}` }
335      await setCase($, python, dir, e.origin.kind === 'composer')
336      await $.ui.open({ id: PANE, title: 'zebra · case board' })
337      return {
338        text: `Case created at ${dir} (case.json, records/, evidence/, reports/). Put reports and lab results in records/.`,
339        context: [`A new zebra case is active at ${dir}. Next useful step: /zebra-mod:zebra-intake to turn records into an HPO profile.`],
340      }
341    }
342    if (verb === 'close') {
343      await setCase($, python, null, e.origin.kind === 'composer')
344      await $.ui.close({ id: PANE })
345      return { text: 'No active case.' }
346    }
347    if (verb === 'doctor') {
348      const got = await runZebra($, python, ['doctor'], 90_000)
349      return { text: got.ok ? doctorText(got.result) : `zebra doctor failed: ${got.error?.message}` }
350    }
351    if (verb === 'ledger') {
352      const active = await read($, casePath)
353      if (!active) return { text: 'No active case.' }
354      // --tail: the CLI keeps the newest rows and reports the true total (an output trim would keep the oldest)
355      const got = await runZebra($, python, ['case', 'ledger', '--case', active, '--tail', '25'])
356      if (!got.ok) return { text: `Could not read the ledger: ${got.error?.message ?? 'unknown error'}` }
357      const r = (got.result ?? {}) as { total?: number; rows?: Array<Record<string, unknown>> }
358      const rows = r.rows ?? []
359      const tail = rows.map(row => `${row.eid}  ${row.db}  ${row.record ?? ''}  ${row.url ?? ''}`)
360      return { text: rows.length ? `${r.total ?? rows.length} evidence rows (newest ${rows.length}):\n${tail.join('\n')}` : 'The evidence ledger is empty.' }
361    }
362    return { text: `Unknown: /zebra ${verb}. Try /zebra help.` }
363  })
364
365  // ------------------------------------------------------------ the case board
366
367  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
368    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
369    const b = await read($, board)
370    if (!b) {
371      return (
372        <Box flexDirection="column">
373          <Text dimColor>No active case. /zebra new &lt;dir&gt; [title]</Text>
374        </Box>
375      )
376    }
377    return (
378      <Box flexDirection="column">
379        <Text bold>🦓 {b.title}</Text>
380        <Text dimColor>
381          {b.role} · {b.evidence_count} evidence rows · {b.identifiers} protected identifiers
382        </Text>
383        <Markdown key="body" text={boardMarkdown(b)} />
384        <Box flexDirection="row" gap={1}>
385          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refreshBoard($, python)} />
386          <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
387        </Box>
388      </Box>
389    )
390  })
391}
392
393// ------------------------------------------------------------ $-using helpers (top level, as the engine requires)
394
395async function runZebra($: EngineInterface, python: string, args: readonly string[], timeoutMs = 120_000): Promise<Envelope> {
396  const active = await read($, casePath)
397  const env: Record<string, string> = { ZEBRA_DEADLINE_MS: String(Math.max(5_000, timeoutMs - 10_000)) }
398  if (active) env.ZEBRA_CASE = active
399  try {
400    const ran = await $.process.run([python, `${$.plugin.root}/bin/zebra`, '--json', ...args], { env, timeoutMs })
401    if (ran.isStdoutTruncated) {
402      return { ok: false, error: { type: 'OutputTooLarge', message: 'the CLI wrote more than 4 MiB; narrow the query (fewer items, a shorter range)' } }
403    }
404    const out = ran.stdout.trim()
405    if (!out) {
406      // a traceback ends with the exception, so keep the tail, not the head
407      const err = (ran.stderr || `exit ${ran.exitCode}`).trim()
408      return { ok: false, error: { type: 'NoOutput', message: err.slice(-2000) } }
409    }
410    return JSON.parse(out) as Envelope
411  } catch (err) {
412    return { ok: false, error: { type: 'ProcessError', message: String(err).slice(0, 2000) } }
413  }
414}
415
416/** Run the CLI's version check now and record the answer. */
417async function checkCli($: EngineInterface, python: string): Promise<Ready> {
418  let r: Ready
419  try {
420    const v = await $.process.run([python, `${$.plugin.root}/bin/zebra`, '--version'], { timeoutMs: 20_000 })
421    const version = v.exitCode === 0 ? v.stdout.trim() : null
422    r = { python, version, error: version ? null : (v.stderr || 'zebra CLI failed').slice(0, 300) }
423  } catch (err) {
424    r = { python: null, version: null, error: String(err).slice(0, 300) }
425  }
426  await update($, ready, () => r)
427  return r
428}
429
430/**
431 * Ask the person whether a zebra tool may run; no one to ask means no. The engine's permission
432 * dialog only opens for tools it runs itself, so this is an AskUserQuestion. An ask that comes
433 * from the session's mode may be answered once for the session; one a settings rule asks for
434 * (`rule` set) is asked every time, as the person configured.
435 */
436async function approved($: EngineInterface, def: ToolDef, verdict: { reason?: string; rule?: unknown }): Promise<boolean> {
437  const sessionable = verdict.rule === undefined
438  if (sessionable && (await read($, trusted)).includes(def.name)) return true
439  const always = `Allow ${def.name} for this session`
440  const options = sessionable ? ['Allow', always, 'Deny'] : ['Allow', 'Deny']
441  try {
442    const why = verdict.reason ? `${verdict.reason.replace(/[??.。]\s*$/, '')}. ` : ''
443    const answer = await $.ui.ask(`${why}Allow zebra-mod to run ${def.name}?`, options)
444    if (sessionable && answer === always) {
445      await update($, trusted, t => (t.includes(def.name) ? t : [...t, def.name]))
446      return true
447    }
448    return answer === 'Allow'
449  } catch {
450    return false // dismissed, or a headless run with nobody to ask
451  }
452}
453
454/** The case directory at `dir`, or null when it holds no zebra case. */
455async function caseAt($: EngineInterface, dir: string): Promise<string | null> {
456  try {
457    if (!(await $.fs.exists(`${dir}/case.json`))) return null
458    const raw = JSON.parse(await $.fs.read(`${dir}/case.json`)) as { schema?: string }
459    return raw.schema === 'zebra.case/1' ? dir : null
460  } catch {
461    return null
462  }
463}
464
465/** The case folder this directory lies inside (a session started in <case>/records), up to four levels up. */
466async function enclosingCase($: EngineInterface, cwd: string): Promise<string | null> {
467  let dir = cwd.replace(/\/+$/, '')
468  for (let i = 0; i < 4; i++) {
469    const parent = dir.slice(0, dir.lastIndexOf('/'))
470    if (!parent || parent === dir) return null
471    const found = await caseAt($, parent)
472    if (found) return found
473    dir = parent
474  }
475  return null
476}
477
478/** The case last used in this working directory, if any. Cases are never shared between projects. */
479async function rememberedFor($: EngineInterface, cwd: string): Promise<string | null> {
480  const saved = await $.store.get('casesByDir')
481  if (saved && typeof saved === 'object') {
482    const held = (saved as Record<string, unknown>)[cwd]
483    if (typeof held === 'string') return held
484  }
485  return null
486}
487
488async function refreshBoard($: EngineInterface, python: string): Promise<void> {
489  const active = await read($, casePath)
490  if (!active) {
491    await update($, board, () => null)
492    await update($, guard, () => [])
493    $.ui.status(undefined)
494    return
495  }
496  const got = await runZebra($, python, ['case', 'summary', active], 30_000)
497  if (got.ok && got.result) {
498    const b = got.result as Board
499    await update($, board, () => b)
500    $.ui.status(pinCaseStatus ? statusLine(b) : undefined)
501  } else {
502    $.ui.status(`zebra: case ${active} unreadable`)
503  }
504  const held = await identifiersNow($, active)
505  if (!held.isClosed) await update($, guard, () => held.ids)
506}
507
508/**
509 * The case's protected identifiers, read now rather than from the last poll, so a
510 * `zebra case identifiers --add` in the same turn is already in force.
511 * `isClosed` means the file could not be read: the caller must refuse outbound calls.
512 */
513async function identifiersNow($: EngineInterface, active: string | null): Promise<{ ids: string[]; isClosed: boolean }> {
514  const session = await read($, sessionIds)
515  if (!active) return { ids: session, isClosed: false }
516  try {
517    const raw = JSON.parse(await $.fs.read(`${active}/case.json`)) as { privacy?: { identifiers?: unknown } }
518    const listed = raw.privacy?.identifiers
519    if (listed === undefined || listed === null) return { ids: session, isClosed: false }
520    if (!Array.isArray(listed)) return { ids: session, isClosed: true }
521    return { ids: [...session, ...listed.filter((s): s is string => typeof s === 'string' && s.trim().length >= 2)], isClosed: false }
522  } catch {
523    const kept = await read($, guard)
524    return { ids: [...session, ...kept], isClosed: true }
525  }
526}
527
528/**
529 * case_update with no case open. Identifiers are the one thing worth keeping without a case: a
530 * parent pastes a clinic note before any folder exists, and the gate must know the child's name
531 * from that moment. They are held in memory for this session (written nowhere) and added to the
532 * next case opened or created. Anything else in the call needs a case and is reported as not
533 * recorded. Returns the tool's answer, or undefined when the call carries no identifiers.
534 */
535async function holdForSession($: EngineInterface, input: Record<string, unknown>): Promise<string | undefined> {
536  const raw = Array.isArray(input.identifiers) ? input.identifiers : []
537  const values = raw.filter((v): v is string => typeof v === 'string' && v.trim().length >= 2).map(v => v.trim())
538  if (values.length === 0) return undefined
539  const held = await update($, sessionIds, list => [...new Set([...list, ...values])])
540  const other = Object.keys(input).filter(k => k !== 'identifiers' && k !== 'tool' && k !== 'tool_use_id' && input[k] !== undefined)
541  const fields = TOOLS.find(t => t.name === 'case_update')
542  const declared = new Set(Object.keys(((fields?.inputSchema as { properties?: object })?.properties ?? {}) as object))
543  const lost = other.filter(k => declared.has(k))
544  return JSON.stringify({
545    warnings: lost.length ? [`no case is open, so ${lost.join(', ')} ${lost.length === 1 ? 'was' : 'were'} not recorded: /zebra new <dir> starts a case`] : [],
546    result: {
547      identifiers: { protected_for_this_session: held.length },
548      note: 'No case is open: these identifiers are protected for the rest of this session (kept in memory only, never written) and will be added to the next case opened or created.',
549    },
550  })
551}
552
553async function setCase($: EngineInterface, python: string, path: string | null, isInteractive: boolean): Promise<void> {
554  await update($, casePath, () => path)
555  if (isInteractive) {
556    const cwd = await $.session.cwd()
557    const saved = await $.store.get('casesByDir')
558    const map: Record<string, unknown> = saved && typeof saved === 'object' ? { ...(saved as Record<string, unknown>) } : {}
559    if (path) map[cwd] = path
560    else delete map[cwd]
561    await $.store.set('casesByDir', map)
562  }
563  if (path) await $.env.set('ZEBRA_CASE', path)
564  else await $.env.set('ZEBRA_CASE', undefined)
565  if (path) {
566    const pending = await read($, sessionIds)
567    if (pending.length) {
568      const got = await runZebra($, python, ['case', 'apply', path, '--ops', JSON.stringify({ identifiers: pending })], 30_000)
569      if (!got.ok) $.ui.toast(`zebra-mod: the identifiers held for this session could not be added to ${path}: ${got.error?.message ?? 'unknown error'}`)
570    }
571  }
572  await refreshBoard($, python)
573}
574
575/** The first of `paths` that lies inside the case folder, resolved through links. */
576async function firstInsideCase($: EngineInterface, active: string, paths: readonly string[], root?: unknown): Promise<string | undefined> {
577  const caseRoot = await $.fs.stat(active, { resolve: true }).catch(() => undefined)
578  const realRoot = caseRoot?.realPath
579  if (realRoot === undefined) return undefined
580  const home = (await $.env.get('HOME')) ?? ''
581  const base = typeof root === 'string' && root ? root.replace(/\/$/, '') : ''
582  for (const p of paths) {
583    let q = p.replace(/^\$HOME(?=\/|$)/, home).replace(/^~(?=\/|$)/, home)
584    if (base && !q.startsWith('/')) q = `${base}/${q}`
585    const stat = await $.fs.stat(q, { resolve: true }).catch(() => undefined)
586    const real = stat?.realPath
587    if (real !== undefined && (real === realRoot || real.startsWith(`${realRoot}/`))) return p
588  }
589  return undefined
590}
591
592function artifactPaths(input: unknown): string[] {
593  const i = (input ?? {}) as { file_path?: unknown; file_paths?: unknown; files?: unknown; root?: unknown }
594  const root = typeof i.root === 'string' && i.root ? i.root.replace(/\/$/, '') : ''
595  const under = (p: string) => (root && !p.startsWith('/') ? `${root}/${p}` : p)
596  const out: string[] = []
597  if (typeof i.file_path === 'string') out.push(i.file_path)
598  if (Array.isArray(i.file_paths)) for (const p of i.file_paths) if (typeof p === 'string') out.push(p)
599  if (Array.isArray(i.files)) {
600    for (const f of i.files) {
601      if (typeof f === 'string') out.push(under(f))
602      else if (f && typeof f === 'object' && typeof (f as { path?: unknown }).path === 'string') out.push(under((f as { path: string }).path))
603    }
604  } else if (i.files && typeof i.files === 'object') {
605    for (const v of Object.values(i.files as Record<string, unknown>)) {
606      if (typeof v === 'string') out.push(under(v))
607      else if (v && typeof v === 'object' && typeof (v as { from?: unknown }).from === 'string') out.push(under((v as { from: string }).from))
608    }
609  }
610  return out
611}
612
613/** The parts of an Artifact call that are published themselves (title, description), not paths. */
614const ARTIFACT_PATH_KEYS = new Set(['file_path', 'file_paths', 'files', 'root', 'out_dir', 'url', 'from_url', 'type_url',
615  'path', 'paths'])
616
617function artifactFields(input: unknown): Record<string, unknown> {
618  const i = (input ?? {}) as Record<string, unknown>
619  const out: Record<string, unknown> = {}
620  for (const [k, v] of Object.entries(i)) if (!ARTIFACT_PATH_KEYS.has(k)) out[k] = v
621  return out
622}
623
624/**
625 * The identifiers of every other case a shell command names with --case: a clinician with several
626 * cases open in turn may query for one while another is active. Unreadable case files add nothing
627 * here (the active case's own unreadable file still closes the gate).
628 */
629async function namedCaseIdentifiers($: EngineInterface, command: string, active: string | null): Promise<string[]> {
630  const words = shellWords(command)
631  const named = new Set<string>()
632  words.forEach((w, i) => {
633    if (w === '--case' && words[i + 1]) named.add(words[i + 1] as string)
634    else if (w.startsWith('--case=')) named.add(w.slice('--case='.length))
635  })
636  if (named.size === 0) return []
637  const home = (await $.env.get('HOME')) ?? ''
638  const out: string[] = []
639  for (const raw of named) {
640    const dir = raw.replace(/^\$HOME(?=\/|$)/, home).replace(/^~(?=\/|$)/, home).replace(/\/+$/, '')
641    if (!dir || dir === active?.replace(/\/+$/, '')) continue
642    try {
643      const data = JSON.parse(await $.fs.read(`${dir}/case.json`)) as { privacy?: { identifiers?: unknown } }
644      const listed = data.privacy?.identifiers
645      if (Array.isArray(listed)) out.push(...listed.filter((x): x is string => typeof x === 'string' && x.trim().length >= 2))
646    } catch {
647      // not a case, or not readable: nothing to add
648    }
649  }
650  return out
651}
652
653/**
654 * What a page about to be published carries: the case's identifiers and the ID/phone/email
655 * patterns, checked in every file it publishes. A file that cannot be read (missing, over the
656 * 4 MiB read limit) is reported as unread, so the caller asks instead of passing it unseen.
657 */
658async function artifactLeak($: EngineInterface, input: unknown, ids: readonly string[]): Promise<{ leak?: string; unread: string[] }> {
659  const unread: string[] = []
660  const root = (input as { root?: unknown })?.root
661  const base = typeof root === 'string' && root ? root.replace(/\/$/, '') : ''
662  for (const p of artifactPaths(input)) {
663    const path = base && !p.startsWith('/') ? `${base}/${p}` : p
664    const text = await $.fs.read(path).catch(() => undefined)
665    if (text === undefined) {
666      unread.push(p)
667      continue
668    }
669    // paragraph by paragraph (tags, entities and soft line breaks folded first): numbers from all
670    // over a page must never join into a record number, but a name split by a line break still counts
671    const chunks = text.split(/\n\s*\n|<\/(?:p|div|li|tr|td|th|h[1-6]|section|table)>|<br\s*\/?>/i)
672      .map(c => c.replace(/\s*\n\s*/g, ' '))
673    const hit = guardInput(chunks, ids)
674    if (hit) return { leak: `${hit} (in ${p})`, unread }
675  }
676  return { unread }
677}
678
679// ------------------------------------------------------------ text
680
681async function absolute($: EngineInterface, p: string): Promise<string> {
682  const home = (await $.env.get('HOME')) ?? ''
683  const expanded = p === '~' ? home : p.startsWith('~/') ? `${home}/${p.slice(2)}` : p
684  const raw = expanded.startsWith('/') ? expanded : `${await $.session.cwd()}/${expanded}`
685  const parts: string[] = []
686  for (const part of raw.split('/')) {
687    if (part === '' || part === '.') continue
688    if (part === '..') parts.pop()
689    else parts.push(part)
690  }
691  return `/${parts.join('/')}`
692}
693
694function statusLine(b: Board): string {
695  const present = b.phenotypes.filter(p => p.status === 'present').length
696  const lead = b.hypotheses.find(h => h.status === 'confirmed' || h.status === 'leading')
697  return `🦓 ${b.title} · HPO ${present} · variants ${b.variants.length}${lead ? ` · ${lead.status}: ${lead.disease}` : ''} · E${b.evidence_count}`
698}
699
700/** Case text is the person's own; it must not be read as Markdown when the board draws it. */
701function plain(text: string | null | undefined, fallback = ''): string {
702  const t = (text ?? fallback).replace(/[\r\n]+/g, ' ')
703  return t.replace(/[\\`*_[\]<>|#~]/g, m => `\\${m}`)
704}
705
706function boardMarkdown(b: Board): string {
707  const lines: string[] = []
708  const present = b.phenotypes.filter(p => p.status === 'present')
709  const excluded = b.phenotypes.filter(p => p.status === 'excluded')
710  lines.push(`**Phenotypes** (${present.length} present, ${excluded.length} excluded)`)
711  for (const p of present.slice(0, 20)) lines.push(`- ${plain(p.label, p.id)} (${plain(p.id)})`)
712  if (present.length > 20) lines.push(`- … ${present.length - 20} more`)
713  for (const p of excluded.slice(0, 8)) lines.push(`- ${plain(p.label, p.id)} (${plain(p.id)}, absent)`)
714  lines.push('', `**Variants** (${b.variants.length})`)
715  for (const v of b.variants) {
716    lines.push(`- ${plain(v.gene)} ${plain(v.label)} ${plain(v.zygosity)} — ${plain(v.classification, 'unclassified')}`)
717  }
718  lines.push('', `**Hypotheses** (${b.hypotheses.length})`)
719  for (const h of b.hypotheses) lines.push(`- [${plain(h.status)}] ${plain(h.disease)} (+${h.support} / −${h.against})`)
720  if (b.therapy_leads.length) {
721    lines.push('', `**Therapy leads** (${b.therapy_leads.length})`)
722    for (const t of b.therapy_leads) lines.push(`- [${plain(t.kind)}] ${plain(t.name)}${t.status ? ` — ${plain(t.status)}` : ''}`)
723  }
724  if (b.questions.length) {
725    lines.push('', `**Questions for the care team** (${b.questions.length})`)
726    for (const q of b.questions.slice(0, 10)) lines.push(`- ${plain(q)}`)
727  }
728  return lines.join('\n')
729}
730
731type DemoResult = {
732  path: string
733  reused: boolean
734  title: string
735  records: string[]
736  identifiers: number
737  first_prompt: string
738  next_prompts: string[]
739}
740
741/** The language of the guide: an explicit `zh`/`en`, else the open case's, else LANG. */
742async function guideIsZh($: EngineInterface, asked: string | undefined): Promise<boolean> {
743  if (asked === 'zh') return true
744  if (asked === 'en') return false
745  const b = await read($, board)
746  if (b?.language) return b.language.toLowerCase().startsWith('zh')
747  const env = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
748  return env.toLowerCase().startsWith('zh')
749}
750
751function demoText(d: DemoResult, zh: boolean, isFilled: boolean): string {
752  const next = d.next_prompts.map(q => `- ${q}`).join('\n')
753  if (zh) {
754    return [
755      `🦓 **已打开示例病例**「${d.title}」`,
756      `\`${d.path}\``,
757      '',
758      `- records/ 里有两份合成资料:${d.records.join('、')}。`,
759      `- 其中的姓名、出生日期、电话和病历号已登记为受保护身份信息(${d.identifiers} 项),隐私闸门会拦住带有它们的外发查询。`,
760      isFilled
761        ? '- **第一个问题已经放进输入框,按回车就开始。** 它会整理病历、做鉴别诊断、解读变异;过程中能看到工具行、奔跑的斑马和右侧病例看板的变化。'
762        : `- 把这个问题发给 Claude 就开始:\n\n  ${d.first_prompt}`,
763      '',
764      '之后可以接着问:',
765      next,
766      '',
767      '用你自己的资料:`/zebra new ~/cases/<名字>`,把病历、化验单、基因报告放进它的 records/,再直接提问。',
768    ].join('\n')
769  }
770  return [
771    `🦓 **Demo case open:** ${d.title}`,
772    `\`${d.path}\``,
773    '',
774    `- records/ holds two synthetic files: ${d.records.join(', ')}.`,
775    `- The name, date of birth, phone and record numbers in them are registered as protected identifiers (${d.identifiers}); the privacy gate keeps them out of every outgoing query.`,
776    isFilled
777      ? '- **The first question is in the prompt box: press Enter.** It reads the records, ranks a differential and interprets the variant; you will see its tool rows, the galloping zebra and the case board on the right fill in.'
778      : `- Send Claude this to begin:\n\n  ${d.first_prompt}`,
779    '',
780    'Then try:',
781    next,
782    '',
783    'Your own records: `/zebra new ~/cases/<name>`, put reports, lab sheets and the genetic report in its records/, and ask.',
784  ].join('\n')
785}
786
787/** `/zebra`: what the mod does and how to start, in the person's language. */
788function helpText(r: Ready | null, active: string | null, zh: boolean): string {
789  const version = r?.version ?? (zh ? `未就绪(${r?.error ?? '尚未检查 Python'})` : `not ready (${r?.error ?? 'python not checked'})`)
790  if (zh) {
791    return [
792      `🦓 **zebra-mod** · 罕见病研究工作台 · ${version} · 当前病例:${active ?? '无'}`,
793      '',
794      '**怎么用:直接提问。** 用中文或英文描述症状,贴检查结果或基因报告,Claude 会自己调用 zebra-mod 去查公开数据库、做计算,每个结论都标出处。不需要记命令。例如:',
795      '- 孩子 6 个月开始发热抽搐,基因报告说 SCN1A c.2134C>T,这是什么意思?',
796      '- 这些表现可能是什么病:头围小、听力下降、走路晚,肌张力正常',
797      '- Dravet 综合征现在有哪些获批的药和在招募的临床试验?我们在中国',
798      '',
799      '**先试一下:`/zebra demo`** 打开一个合成示例病例,第一个问题会放进输入框,按回车即可。',
800      '',
801      '**能做什么**',
802      '- 鉴别诊断:病历 → 标准表型(HPO)→ 三个来源分别排序 → 该做哪种基因检测',
803      '- 变异解读:ClinVar、gnomAD、预测分数 → ACMG 证据,分数由程序计算',
804      '- 拷贝数变异、外显子缺失、SMN1 拷贝数、重复扩增;剪接与非编码变异的序列功能预测',
805      '- 治疗与试验:获批药物(含中国获批与医保)、在招募的试验(含中国中心)、反义寡核苷酸与碱基编辑可行性',
806      '- 统计:共分离、再发风险、携带频率;文献检索;在本机重分析 VCF',
807      '- 给家属的说明信、就诊准备单,导出 Word / PDF;隔几个月复查有无新进展',
808      '',
809      '**病例命令**(病例文件只保存在本机)',
810      '`/zebra demo` 示例病例 · `/zebra new <目录> [标题]` 新建 · `/zebra case <目录>` 切换 · `/zebra board` 病例看板 · `/zebra ledger` 证据台账 · `/zebra doctor` 环境自检 · `/zebra close` 关闭病例',
811      '',
812      '隐私:Claude 读到的病历会照常发给模型服务商;zebra-mod 只保证已登记的姓名、证件号、病历号不会出现在它对外的数据库查询里。研究用途,不能代替医生。',
813    ].join('\n')
814  }
815  return [
816    `🦓 **zebra-mod** · rare-disease research workstation · ${version} · active case: ${active ?? 'none'}`,
817    '',
818    '**How to use it: just ask.** Describe symptoms, paste test results or a genetic report, in English or Chinese; Claude calls zebra-mod to query public databases and compute, and cites a source for every claim. No commands to learn. For example:',
819    '- My daughter has had seizures with fever since 6 months; her report says SCN1A c.2134C>T. What does it mean?',
820    '- What could this be: small head, hearing loss, walked late, normal muscle tone',
821    '- Which treatments are approved for Dravet syndrome, and are any trials recruiting?',
822    '',
823    '**Try it first: `/zebra demo`** opens a synthetic demo case and puts the first question in the prompt box; press Enter.',
824    '',
825    '**What it does**',
826    '- Differential diagnosis: records → HPO phenotypes → three rankings kept apart → which test finds each candidate',
827    '- Variant interpretation: ClinVar, gnomAD, predictors → ACMG evidence, points computed by code',
828    '- CNVs, exon deletions, SMN1 copy number, repeat expansions; sequence-to-function for splicing and non-coding variants',
829    '- Therapy and trials: approved drugs (incl. China approval and reimbursement), recruiting trials, antisense and base-editing feasibility',
830    '- Statistics: segregation, recurrence risk, carrier frequency; literature; VCF reanalysis on this machine',
831    '- A family letter and a visit-preparation sheet, exported to Word / PDF; rechecks months later',
832    '',
833    '**Case commands** (case files stay on this machine)',
834    '`/zebra demo` · `/zebra new <dir> [title]` · `/zebra case <dir>` · `/zebra board` · `/zebra ledger` · `/zebra doctor` · `/zebra close`',
835    '',
836    'Privacy: records Claude reads reach the model provider as in any session; zebra-mod keeps registered names and record numbers out of its outgoing database queries. Research-grade, not a substitute for a clinician.',
837  ].join('\n')
838}
839
840function doctorText(result: unknown): string {
841  const r = (result ?? {}) as {
842    checks?: Array<{ name: string; ok: boolean; optional?: boolean; detail?: string }>
843    summary?: { ok?: number; failed?: number; optional_missing?: number }
844  }
845  // ○ marks what zebra works without (research keys, offline data): not a failure
846  const rows = (r.checks ?? []).map(c => `${c.ok ? '✓' : c.optional ? '○' : '✗'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`)
847  const s = r.summary
848  if (s) rows.push(`${s.ok ?? 0} ok, ${s.failed ?? 0} failed, ${s.optional_missing ?? 0} optional not set up (○)`)
849  return rows.length ? rows.join('\n') : JSON.stringify(result, null, 1)
850}
851
852/** The longest list inside `result`, so a too-large answer loses items rather than its provenance. */
853function trimLongestList(result: unknown): { result: unknown; dropped: number; key: string } | undefined {
854  if (!result || typeof result !== 'object') return undefined
855  let best: { holder: Record<string, unknown>; key: string; list: unknown[] } | undefined
856  const walk = (value: unknown, depth: number): void => {
857    if (depth > 4 || !value || typeof value !== 'object') return
858    for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
859      if (Array.isArray(v) && v.length > 1 && (best === undefined || v.length > best.list.length)) {
860        best = { holder: value as Record<string, unknown>, key, list: v }
861      }
862      walk(v, depth + 1)
863    }
864  }
865  walk(result, 0)
866  if (!best) return undefined
867  const keep = Math.max(1, Math.floor(best.list.length / 2))
868  const dropped = best.list.length - keep
869  best.holder[best.key] = best.list.slice(0, keep)
870  return { result, dropped, key: best.key }
871}
872
873function formatEnvelope(def: ToolDef, got: Envelope): string {
874  if (!got.ok) {
875    return `zebra ${def.name} failed: ${got.error?.type ?? 'error'}: ${got.error?.message ?? 'no message'}`
876  }
877  const warnings = [...(got.warnings ?? [])]
878  // Pair each source with the evidence id the ledger gave it: one claim, one row to cite.
879  const ledger = got.ledger ?? []
880  const sources = (got.sources ?? []).map((src, i) =>
881    ledger.length === (got.sources ?? []).length && src && typeof src === 'object'
882      ? { eid: ledger[i], ...(src as Record<string, unknown>) }
883      : src)
884  // warnings, ledger and sources first: they are what the answer must cite, so a
885  // trim never costs them. The result is trimmed until the whole envelope fits.
886  let body = { warnings, ledger, sources, result: got.result }
887  let text = JSON.stringify(body)
888  for (let i = 0; i < 12 && text.length > MAX_RESULT_CHARS; i++) {
889    const trimmed = trimLongestList(body.result)
890    if (!trimmed) break
891    warnings.push(`zebra-mod trimmed ${trimmed.dropped} of "${trimmed.key}" to fit the tool result; run the same query with the zebra CLI and --json for all of it`)
892    body = { ...body, warnings, result: trimmed.result }
893    text = JSON.stringify(body)
894  }
895  if (text.length > MAX_RESULT_CHARS) {
896    body = {
897      warnings: [...warnings, 'zebra-mod could not fit this result; only its provenance is shown. Run the same query with the zebra CLI and --json.'],
898      ledger: body.ledger,
899      sources: body.sources,
900      result: null,
901    }
902    text = JSON.stringify(body)
903  }
904  return text
905}
906
hooks/doctrine.ts 45 lines
1import type { Board } from '../types'
2
3// The research doctrine zebra-mod adds to the system prompt. Short on purpose:
4// the skills carry the procedures; this carries the rules every answer keeps.
5export const DOCTRINE = `# zebra-mod: rare-disease research mode
6
7This Claude Code has zebra-mod: a rare-disease research workstation for patients and families on a diagnostic odyssey, clinicians and researchers. Its tools (mcp__zebra-mod__*) query live public databases (HPO, Monarch, Orphanet, ClinVar, gnomAD, Ensembl VEP, ClinGen, PanelApp, Open Targets, ClinicalTrials.gov, Europe PMC, PubTator/LitVar, SpliceAI) and run local analyses; every result carries its sources and, with an active case, is written to the case's evidence ledger (ids E1, E2, ...). The skills (/zebra-mod:zebra-start to start) hold the procedures.
8
9Rules for every answer in this mode:
100. Urgent first. A symptom happening now that needs care today (a seizure past 5 minutes, suspected metabolic decompensation, respiratory or cardiac decline, a stroke-like episode, adrenal crisis) is said first, plainly, with "contact your doctor or go to the emergency department" — before any analysis. Read /zebra-mod:zebra-safety for those, and for the drug, anaesthesia and procedure hazards of specific rare diseases.
111. Evidence, not recall. A gene–disease link, a variant's classification or frequency, a prevalence, a drug's approval status, a trial, a paper: state it only from a tool result or a source fetched in this session, and cite it (ledger id, PMID, database record). What was not retrieved is "not checked", never filled in from memory. Never synthesize evidence.
122. Code scores, you interpret. Phenotype rankings, ACMG points and statistics come from zebra tools; explain them, question their inputs, never invent or adjust a number.
133. Identifiers are verified, not written from memory: HPO, OMIM, ORPHA, MONDO ids, HGVS, rsIDs, NCT numbers and PMIDs appear only as tools returned them.
144. Clarify, never invent. Genome assembly (GRCh38/GRCh37), transcript, zygosity, inheritance, sex and ancestry change conclusions; when one is missing and matters, ask, or state the conclusion as conditional on it.
155. Research, not a clinical report. Classifications here are research-grade until an accredited laboratory or clinical geneticist confirms them. No dosing, no stopping or starting a treatment; a possible diagnosis is a question for the care team, never news delivered to a family as fact.
166. Sequence-to-function predictions (SpliceAI, AlphaGenome, Evo 2, AlphaMissense) are hypotheses: give model, score and what level they speak to (molecular, cellular); combine independent axes by agreement, never average them into one number.
177. Look beyond the obvious ("think zebras"), and say how strong each lead is: established, emerging, speculative.
188. Privacy: zebra writes case files only on this machine, but what you read (records, reports, this conversation) reaches the model provider as in any Claude Code session — say so if asked. When records name the patient, first register those identifiers with case_update \`identifiers\` (name in every spelling, date of birth, record and ID numbers); the privacy gate then keeps them out of every outgoing call. Never send names, birth dates, record numbers or raw genome files to a web service; query with HPO ids, genes and variants. Do not write them back in your replies either (address the person as 您 / you and the patient as 孩子 / your child).
199. Register: with families plain, warm and exact (Chinese by default for Chinese speakers); with clinicians and researchers precise and technical.
2010. Work through the skills: zebra-start routes to the procedure for the question (intake, diagnose, variant, reanalysis, s2f, therapy, stats, literature, family, report); a wide search goes to the zebra-mod subagents, several in one message.\``
21
22// What a session without an active case carries under the default `auto` mode: the mod is
23// installed in Claude Code used for everything else too, so it speaks only when asked about
24// a rare disease and never changes how unrelated work is done.
25export const DOCTRINE_BRIEF = `# zebra-mod (installed)
26
27This Claude Code has zebra-mod, a rare-disease research workstation. When — and only when — the person asks about a rare or undiagnosed disease, a patient's records, a genetic test report or variant, or treatment of a rare disease, load /zebra-mod:zebra-start and follow it. In short: a symptom that needs care today comes first ("contact your doctor or go to the emergency department"); state facts only from the mod's tools or sources fetched in this session, and cite them; never write HPO/OMIM/ORPHA ids, HGVS, rsIDs, NCT numbers or PMIDs from memory; research-grade only, no dosing; never send a patient's name, birth date or record numbers to a web service, nor repeat them in replies. For anything else, ignore this section.`
28
29export function renderDoctrine(base: string, active: string | null, board: Board | null): string {
30  if (!active) {
31    return `${base}\n\nNo active case. /zebra new <dir> [title] starts one; without one, tool results are not written to a ledger.`
32  }
33  const lines = [`${base}`, '', `Active case: ${active}${board ? ` — "${board.title}" (role: ${board.role}, language: ${board.language ?? 'zh'})` : ''}.`]
34  if (board) {
35    const present = board.phenotypes.filter(p => p.status === 'present').length
36    lines.push(
37      `It holds ${present} present phenotypes, ${board.variants.length} variants, ${board.hypotheses.length} hypotheses, ${board.tests?.length ?? 0} tests done, ${board.evidence_count} evidence rows. Read it with mcp__zebra-mod__case_status before relying on it; record findings with mcp__zebra-mod__case_update.`,
38    )
39    if (board.identifiers === 0) {
40      lines.push('No protected identifiers are registered for this case: if its records name the patient, register them (case_update identifiers) before any outgoing lookup.')
41    }
42  }
43  return lines.join('\n')
44}
45
hooks/tools.ts 671 lines
1// The tools zebra-mod gives the model. Each one is a thin, typed door onto a
2// `zebra` CLI command (bin/zebra, Python standard library only), so the CLI
3// stays the single implementation: the same answers come back whether the
4// model calls the tool, a skill runs the command in Bash, or a researcher
5// scripts it.
6
7export type ToolDef = {
8  name: string
9  description: string
10  inputSchema: Record<string, unknown>
11  argv: (input: Record<string, unknown>, casePath: string | null) => string[]
12  deferred?: boolean
13  touchesCase?: boolean
14  timeoutMs?: number
15}
16
17const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
18const num = (v: unknown): string | undefined => (typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined)
19const list = (v: unknown): string[] =>
20  Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim()) : []
21const flag = (name: string, v: string | undefined): string[] => (v === undefined ? [] : [name, v])
22
23// What each statistics method accepts, so a params key cannot smuggle --help or --case.
24const STATS_FLAGS: Record<string, string[]> = {
25  segregation: ['ad-meioses', 'xlr-male-meioses', 'ar-affected-sibs', 'ar-unaffected-sibs', 'nonsegregations',
26    'unaffected-carriers', 'full-penetrance'],
27  maxaf: ['prevalence', 'allelic', 'genetic', 'penetrance', 'inheritance', 'an', 'faf95'],
28  carrier: ['prevalence', 'allele-freqs'],
29  recurrence: ['mode', 'penetrance', 'mosaic', 'prior', 'unaffected-sons', 'affected-sons'],
30  fisher: ['a', 'b', 'c', 'd'],
31  burden: ['case-carriers', 'case-n', 'control-carriers', 'control-n'],
32  denovo: ['observed', 'trios', 'mu'],
33  km: ['csv', 'time-col', 'event-col', 'group-col', 'event-coding'],
34  nof1: ['effect', 'sd-diff', 'alpha', 'power', 'treatment', 'control'],
35}
36
37const HPO = { type: 'string', pattern: '^HP:\\d{7}$' }
38const ASSEMBLY = { type: 'string', enum: ['GRCh38', 'GRCh37'], description: 'Genome build of genomic coordinates; ask when unknown.' }
39
40export const TOOLS: ToolDef[] = [
41  {
42    name: 'case_status',
43    description:
44      'Read the active zebra case: phenotypes (HPO), variants, hypotheses, therapy leads, open questions and how many evidence rows back them. Call before relying on what the case holds.',
45    inputSchema: { type: 'object', properties: {} },
46    argv: (_i, casePath) => {
47      if (!casePath) throw new Error('no active case (the person can run /zebra new <dir> or /zebra case <dir>)')
48      return ['case', 'summary', casePath]
49    },
50  },
51  {
52    name: 'case_update',
53    description:
54      'Record findings in the active case. identifiers FIRST when records name the patient: the name (every spelling: 汉字, pinyin), date of birth, record/ID numbers — the privacy gate then keeps them out of every outgoing call; they are never echoed back. profile: role (family/patient/clinician/researcher), language, proband sex/age, consanguinity. phenotypes: HPO terms present or excluded (labels are verified against HPO; never invent an id — find it with hpo_search). variants, hypotheses (with ORPHA/OMIM/MONDO ids and ledger evidence ids for and against), ACMG readings of recorded variants (codes in, class computed by zebra), therapy leads, tests already done (CMA, panel, exome…, with result), family members (relation, affected, genotype — never names), timeline events, questions for the care team, and removals. Several at once.',
55    inputSchema: {
56      type: 'object',
57      properties: {
58        profile: {
59          type: 'object',
60          description: 'who the case is for and how to write to them; proband basics (no names or birth dates)',
61          properties: {
62            title: { type: 'string' },
63            role: { type: 'string', enum: ['family', 'patient', 'clinician', 'researcher'] },
64            language: { type: 'string', description: 'zh or en' },
65            sex: { type: 'string', enum: ['female', 'male', 'unknown'] },
66            age: { type: 'string', description: 'age or age band, e.g. "2y11m"' },
67            ancestry: { type: 'string' },
68            consanguinity: { type: 'boolean' },
69          },
70        },
71        phenotypes: {
72          type: 'array',
73          items: {
74            type: 'object',
75            properties: {
76              id: HPO,
77              status: { type: 'string', enum: ['present', 'excluded'] },
78              onset: { type: 'string' },
79              source: { type: 'string', description: 'where it is documented, e.g. records/neuro-2023.pdf p2' },
80              note: { type: 'string' },
81            },
82            required: ['id'],
83          },
84        },
85        variants: {
86          type: 'array',
87          items: {
88            type: 'object',
89            properties: {
90              gene: { type: 'string' },
91              hgvs_c: { type: 'string', description: 'with transcript, e.g. NM_001165963.4:c.2134C>T' },
92              hgvs_g: { type: 'string' },
93              hgvs_p: { type: 'string' },
94              vcf: { type: 'string', description: 'chrom-pos-ref-alt, e.g. 2-166001-C-T' },
95              assembly: ASSEMBLY,
96              zygosity: { type: 'string', enum: ['het', 'hom', 'hemi', 'mosaic', 'unknown'] },
97              inheritance: { type: 'string', enum: ['de_novo', 'maternal', 'paternal', 'biparental', 'unknown'] },
98              classification_lab: { type: 'string', description: 'as written on the lab report' },
99              source: { type: 'string' },
100              description: { type: 'string' },
101              kind: {
102                type: 'string',
103                enum: ['small', 'cnv', 'exon_cnv', 'copy_number', 'repeat_expansion'],
104                description: 'small (default) for an SNV/indel; cnv for a CMA/CNV-seq result; exon_cnv for an exon-level deletion or duplication; copy_number for SMN1-type dosage; repeat_expansion for a repeat expansion',
105              },
106              region: { type: 'string', description: 'cnv: chr15:23123715-28193120' },
107              iscn: { type: 'string', description: 'cnv: the ISCN string as reported, e.g. arr[GRCh38] 22q11.21(18648855_21800471)x1' },
108              cnv_type: { type: 'string', enum: ['loss', 'gain'] },
109              copy_number: { type: 'number', description: 'copies reported (SMN1 exon 7 = 0, 1, 2 …)' },
110              exons: { type: 'string', description: 'exon_cnv: 45-50, or a single exon number' },
111              genes: { type: 'array', items: { type: 'string' }, description: 'genes the finding covers, as a tool returned them' },
112              motif: { type: 'string', description: 'repeat: the repeat unit, e.g. CGG' },
113              repeat_count: { type: 'number', description: 'repeat: the number of units reported' },
114              method: { type: 'string', description: 'how it was measured: CMA, CNV-seq, MLPA, ddPCR, repeat-primed PCR …' },
115            },
116          },
117        },
118        hypotheses: {
119          type: 'array',
120          items: {
121            type: 'object',
122            properties: {
123              disease: { type: 'string' },
124              status: { type: 'string', enum: ['leading', 'considered', 'excluded', 'confirmed'] },
125              ids: { type: 'array', items: { type: 'string' }, description: 'ORPHA:33069, OMIM:607208, MONDO:0100135 as tools returned them' },
126              support: { type: 'array', items: { type: 'string' }, description: 'ledger ids, e.g. E4' },
127              against: { type: 'array', items: { type: 'string' } },
128              note: { type: 'string' },
129            },
130            required: ['disease'],
131          },
132        },
133        leads: {
134          type: 'array',
135          items: {
136            type: 'object',
137            properties: {
138              name: { type: 'string' },
139              kind: { type: 'string', enum: ['approved', 'trial', 'repurposing', 'n-of-1', 'supportive', 'other'] },
140              status: { type: 'string' },
141              evidence: { type: 'array', items: { type: 'string' } },
142              note: { type: 'string' },
143            },
144            required: ['name', 'kind'],
145          },
146        },
147        acmg: {
148          type: 'array',
149          description: 'store a research-grade ACMG reading on a recorded variant: the codes you justified (classified by zebra, not by you)',
150          items: {
151            type: 'object',
152            properties: {
153              variant_id: { type: 'string', description: 'v1, v2 ... as case_status lists them' },
154              codes: { type: 'array', items: { type: 'string' } },
155              note: { type: 'string' },
156            },
157            required: ['variant_id', 'codes'],
158          },
159        },
160        questions: { type: 'array', items: { type: 'string' } },
161        identifiers: {
162          type: 'array',
163          items: { type: 'string' },
164          description: 'protected identifiers to add (name in every spelling, date of birth, record/ID numbers); kept out of every outgoing call, never shown back',
165        },
166        tests: {
167          type: 'array',
168          description: 'tests already done and their reported result',
169          items: {
170            type: 'object',
171            properties: {
172              type: { type: 'string', description: 'CMA, karyotype, gene panel, trio exome, genome, MLPA, repeat sizing, metabolic screen, MRI, EEG…' },
173              date: { type: 'string' },
174              result: { type: 'string', description: 'as reported, e.g. "normal", "VUS SCN1A c.…", "arr[GRCh38] 22q11.21(…)x1"' },
175              lab: { type: 'string' },
176              method: { type: 'string' },
177              source: { type: 'string', description: 'which record file it came from' },
178              note: { type: 'string' },
179            },
180            required: ['type'],
181          },
182        },
183        family: {
184          type: 'array',
185          description: 'relatives as the records describe them — relation, affected or not, genotype if tested; never names',
186          items: {
187            type: 'object',
188            properties: {
189              relation: { type: 'string', enum: ['mother', 'father', 'sibling', 'brother', 'sister', 'half-sibling', 'child', 'son', 'daughter', 'maternal grandmother', 'maternal grandfather', 'paternal grandmother', 'paternal grandfather', 'maternal aunt', 'maternal uncle', 'paternal aunt', 'paternal uncle', 'cousin', 'twin', 'other'] },
190              sex: { type: 'string' },
191              affected: { type: ['boolean', 'string'], description: 'true, false or "unknown"' },
192              status: { type: 'string', description: 'alive, deceased, …' },
193              genotype: { type: 'string', description: 'e.g. "het for v1", "not carrier of v1", "not tested"' },
194              age: { type: 'string' },
195              note: { type: 'string' },
196              source: { type: 'string' },
197            },
198          },
199        },
200        timeline: {
201          type: 'array',
202          items: {
203            type: 'object',
204            properties: { date: { type: 'string' }, event: { type: 'string' }, source: { type: 'string' } },
205            required: ['event'],
206          },
207        },
208        remove: {
209          type: 'array',
210          items: {
211            type: 'object',
212            properties: {
213              kind: { type: 'string', enum: ['phenotype', 'variant', 'hypothesis', 'lead', 'test', 'relative'] },
214              id: { type: 'string' },
215            },
216            required: ['kind', 'id'],
217          },
218        },
219      },
220    },
221    argv: (input, casePath) => {
222      if (!casePath) throw new Error('no active case (the person can run /zebra new <dir> or /zebra case <dir>)')
223      return ['case', 'apply', casePath, '--ops', JSON.stringify(input)]
224    },
225    touchesCase: true,
226  },
227  {
228    name: 'hpo_search',
229    description:
230      'Find Human Phenotype Ontology terms for a clinical phrase. Chinese: official Chinese labels once the HPO release is fetched, and the lay phrases families use (走路晚, 抽风, 不会说话, 发热惊厥, 头围小, 听力下降) with or without it — also inside a short sentence ("孩子走路晚"); a negation (无明显抽搐) is not read as the feature and is warned about. English works either way. Returns verified ids with the label, the Chinese label where there is one, and what matched (label, synonym or lay phrase). Use it for every phenotype before recording one.',
231    inputSchema: {
232      type: 'object',
233      properties: { text: { type: 'string' }, limit: { type: 'number', default: 8 } },
234      required: ['text'],
235    },
236    argv: i => ['hpo', 'search', str(i.text) ?? '', ...flag('--limit', num(i.limit))],
237  },
238  {
239    name: 'phenotype_rank',
240    description:
241      'Phenotype-driven differential diagnosis: rank diseases and genes for a set of HPO terms (present, and excluded). Sources: local (offline Resnik best-match over HPO annotations), monarch (Monarch semantic similarity), pubcasefinder (PubCaseFinder). Each source ranks on its own; agreement across them is the signal. Excluded terms are checked against each disease and listed in `excluded_hits` (a disease that usually has a feature the patient lacks) but do not lower the local score — on the phenopacket-store benchmark that ranked better (docs/BENCHMARK.md); read the hits and weigh them yourself, or set excluded_weight 1 for the 0.1.0 penalty. Ties at the top are reported. Measured accuracy (held-out cases whose own paper is not an HPO annotation source): correct disease in the local top 10 for 28%; all held-out cases 70% (an upper bound). from_case uses the active case\'s phenotypes.',
242    inputSchema: {
243      type: 'object',
244      properties: {
245        present: { type: 'array', items: HPO },
246        excluded: { type: 'array', items: HPO },
247        from_case: { type: 'boolean' },
248        sources: { type: 'array', items: { type: 'string', enum: ['local', 'monarch', 'pubcasefinder'] } },
249        top: { type: 'number', default: 15 },
250        local_method: { type: 'string', enum: ['resnik', 'lr'], description: 'local scoring (default resnik)' },
251        excluded_weight: { type: 'number', description: 'local penalty for excluded terms present in a disease: 0 (default, flagged only) to 10; 1 = zebra 0.1.0' },
252      },
253    },
254    argv: i => {
255      const out = ['phenotype', 'rank']
256      const present = list(i.present)
257      const excluded = list(i.excluded)
258      if (present.length) out.push('--present', ...present)
259      if (excluded.length) out.push('--exclude', ...excluded)
260      if (i.from_case === true) out.push('--from-case')
261      const sources = list(i.sources)
262      if (sources.length) out.push('--sources', sources.join(','))
263      return [...out, ...flag('--top', num(i.top)), ...flag('--local-method', str(i.local_method)),
264        ...flag('--excluded-weight', num(i.excluded_weight))]
265    },
266    timeoutMs: 180_000,
267  },
268  {
269    name: 'gene_card',
270    description:
271      'Everything that matters about one gene for rare disease: HGNC identity (aliases resolved), causal disease associations with inheritance (Monarch: OMIM and ClinGen), ClinGen gene–disease validity classes and dosage sensitivity (haploinsufficiency / triplosensitivity), gnomAD constraint (pLI, LOEUF, missense Z), PanelApp (England and Australia) panels with confidence, protein function (UniProt) and AlphaFold model. Use symbols as HGNC spells them.',
272    inputSchema: { type: 'object', properties: { symbol: { type: 'string' } }, required: ['symbol'] },
273    argv: i => ['gene', str(i.symbol) ?? ''],
274  },
275  {
276    name: 'variant_card',
277    description:
278      'Annotate one variant: normalized forms (HGVS, GRCh38/37 coordinates), consequence on the MANE transcript (Ensembl VEP), population frequency (gnomAD, by genetic ancestry group, with filtering AF), ClinVar classification with review status, in-silico predictors (REVEL, AlphaMissense, CADD, SpliceAI where available) and literature mentions (LitVar). Input: HGVS with transcript (NM_...:c.), rsID, or chrom-pos-ref-alt with assembly.',
279    inputSchema: {
280      type: 'object',
281      properties: {
282        variant: { type: 'string', description: 'HGVS, chrom-pos-ref-alt, rsID, or an mtDNA change such as "m.3243A>G 35%"' },
283        assembly: ASSEMBLY,
284        gene: { type: 'string' },
285        heteroplasmy: { type: 'number', description: 'mtDNA only: the reported heteroplasmy, in percent' },
286      },
287      required: ['variant'],
288    },
289    argv: i => [
290      'variant', str(i.variant) ?? '', ...flag('--assembly', str(i.assembly)), ...flag('--gene', str(i.gene)),
291      ...flag('--heteroplasmy', num(i.heteroplasmy)),
292    ],
293    timeoutMs: 180_000,
294  },
295  {
296    name: 'disease_card',
297    description:
298      'One rare disease: identifiers across ORPHA, OMIM, MONDO, ICD; definition, prevalence, inheritance, age of onset, associated genes (Orphanet / Monarch), GeneReviews chapter, whether it is on China\'s national rare disease lists. Input: a name or an id (ORPHA:33069, OMIM:607208, MONDO:0100135).',
299    inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
300    argv: i => ['disease', str(i.query) ?? ''],
301    timeoutMs: 180_000,
302  },
303  {
304    name: 'acmg',
305    description:
306      'ACMG/AMP classification arithmetic. classify: give the evidence codes you have justified (e.g. PVS1, PS2, PM2_Supporting, PP3_Strong, BS1) and get the points (Tavtigian 2020) and the 2015 combining-rule class, with rule warnings. suggest: for a variant, the codes that follow from numbers alone (frequency, REVEL, SpliceAI) with their ClinGen thresholds — judgement codes (PVS1, PS3, PM3, PP1, PS4...) stay yours to justify.',
307    inputSchema: {
308      type: 'object',
309      properties: {
310        mode: { type: 'string', enum: ['classify', 'suggest'] },
311        codes: { type: 'array', items: { type: 'string' } },
312        variant: { type: 'string' },
313        assembly: ASSEMBLY,
314        inheritance: { type: 'string', enum: ['AD', 'AR', 'XLD', 'XLR', 'unknown'] },
315        prevalence: { type: 'number', description: 'suggest: disease prevalence (e.g. 0.0001) for the Whiffin maximum credible AF behind BS1' },
316        allelic: { type: 'number', description: 'suggest: maximum allelic contribution of one variant (0-1]' },
317        genetic: { type: 'number', description: 'suggest: maximum genetic contribution of this gene (0-1]' },
318        penetrance: { type: 'number', description: 'suggest: penetrance (0-1]' },
319        inheritance_mode: { type: 'string', enum: ['monoallelic', 'biallelic'], description: 'suggest: only for XLR, XLD or unknown inheritance; AD/AR choose it themselves' },
320        pm2_max_af: { type: 'number', description: 'suggest: a gene-specific PM2 ceiling from a ClinGen VCEP' },
321        heteroplasmy: { type: 'number', description: 'suggest, mtDNA: heteroplasmy in percent' },
322        no_splice_lookup: { type: 'boolean', description: 'suggest: skip the SpliceAI/Pangolin lookup (±4,999 nt) and use VEP\'s precomputed SpliceAI' },
323      },
324      required: ['mode'],
325    },
326    argv: i => {
327      if (i.mode === 'suggest') {
328        return [
329          'acmg', 'suggest', str(i.variant) ?? '', ...flag('--assembly', str(i.assembly)), ...flag('--inheritance', str(i.inheritance)),
330          ...flag('--prevalence', num(i.prevalence)), ...flag('--allelic', num(i.allelic)), ...flag('--genetic', num(i.genetic)),
331          ...flag('--penetrance', num(i.penetrance)), ...flag('--inheritance-mode', str(i.inheritance_mode)),
332          ...flag('--pm2-max-af', num(i.pm2_max_af)), ...flag('--heteroplasmy', num(i.heteroplasmy)),
333          ...(i.no_splice_lookup === true ? ['--no-splice-lookup'] : []),
334        ]
335      }
336      return ['acmg', 'classify', ...list(i.codes)]
337    },
338    timeoutMs: 240_000,
339  },
340  {
341    name: 's2f_predict',
342    description:
343      'Sequence-to-function predictions for one variant, each model reported separately with its claim ceiling (never averaged): spliceai and pangolin (Broad lookup service; GRCh38 and GRCh37) — the default; and, through the s2f CLI when installed with keys, gpn_msa (conservation, hg38 SNVs, ~1 min), alphagenome (expression/splicing/chromatin in one tissue: needs ontology, e.g. UBERON:0000955 brain; ~5 s) and evo2 (zero-shot likelihood; 3–10 min). A model that cannot run is not_run with the reason. Use for splice-region, deep intronic, UTR, promoter and other non-coding variants, and to test a mechanism.',
344    inputSchema: {
345      type: 'object',
346      properties: {
347        variant: { type: 'string', description: 'chrom-pos-ref-alt, transcript HGVS or rsID' },
348        assembly: ASSEMBLY,
349        models: { type: 'array', items: { type: 'string', enum: ['spliceai', 'pangolin', 'gpn_msa', 'alphagenome', 'evo2'] }, description: 'default spliceai + pangolin' },
350        ontology: { type: 'string', description: 'tissue/cell CURIE for alphagenome (UBERON:/CL:), chosen for the disease' },
351        distance: { type: 'number', description: 'SpliceAI window around the variant (default 500)' },
352        timeout: { type: 'number', description: 'seconds per s2f model run (default 540)' },
353      },
354      required: ['variant'],
355    },
356    argv: i => {
357      const models = list(i.models)
358      return [
359        's2f', 'predict', str(i.variant) ?? '',
360        ...flag('--assembly', str(i.assembly)),
361        '--models', (models.length ? models : ['spliceai', 'pangolin']).join(','),
362        ...flag('--ontology', str(i.ontology)),
363        ...flag('--distance', num(i.distance)),
364        ...flag('--timeout', num(i.timeout) ?? '540'),
365      ]
366    },
367    timeoutMs: 600_000,
368    deferred: true,
369  },
370  {
371    name: 'therapy_landscape',
372    description:
373      'Therapy landscape for a disease or gene (data only, no judgement): approved and investigational drugs and clinical candidates with stage and mechanism (Open Targets / ChEMBL), target tractability for a gene, and EU orphan designations (EMA; US designations are not checked). Input: a disease name or MONDO/EFO id, or a gene symbol (OMIM:/ORPHA: ids are not accepted — use the name). Mechanism fit and N-of-1 routes are for the zebra-therapy skill to judge.',
374    inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'disease name or MONDO/EFO id, or gene symbol' } }, required: ['query'] },
375    argv: i => ['therapy', str(i.query) ?? ''],
376    timeoutMs: 180_000,
377    deferred: true,
378  },
379  {
380    name: 'trials_search',
381    description:
382      'Clinical trials from ClinicalTrials.gov (v2 API): by condition and optionally a keyword (gene, drug, modality), country and status (default RECRUITING; ANY for all). With a country, only trials with a site there are returned, and those sites are listed. Returns NCT ids, phase, status, interventions, ages, eligibility criteria, contacts, sites and URL, and flags a trial whose status looks stale or inconsistent. ChiCTR (the Chinese registry) is not covered: for China, say so and point to it.',
383    inputSchema: {
384      type: 'object',
385      properties: {
386        condition: { type: 'string' },
387        term: { type: 'string', description: 'extra keyword: gene, drug, modality' },
388        country: { type: 'string', description: 'e.g. China, United States; only trials with a site there, and those sites are listed' },
389        full_eligibility: { type: 'boolean', description: 'the whole eligibility text instead of its first 600 characters' },
390        keep_unrelated: { type: 'boolean', description: 'keep trials whose conditions do not name this disease (normally moved to result.filtered)' },
391        status: { type: 'string', enum: ['RECRUITING', 'NOT_YET_RECRUITING', 'ACTIVE_NOT_RECRUITING', 'COMPLETED', 'ANY'] },
392        limit: { type: 'number', default: 20 },
393      },
394      required: ['condition'],
395    },
396    argv: i => [
397      'trials', str(i.condition) ?? '',
398      ...flag('--term', str(i.term)), ...flag('--country', str(i.country)), ...flag('--status', str(i.status)),
399      ...(i.full_eligibility === true ? ['--full-eligibility'] : []), ...flag('--limit', num(i.limit)),
400      ...(i.keep_unrelated === true ? ['--keep-unrelated'] : []),
401    ],
402    deferred: true,
403  },
404  {
405    name: 'literature_search',
406    description:
407      'Search the literature (Europe PMC; with gene/variant, PubTator3 and LitVar for papers that mention them). Returns PMIDs, titles, years, journals and open-access links; read a paper before citing what it says.',
408    inputSchema: {
409      type: 'object',
410      properties: {
411        query: { type: 'string' },
412        gene: { type: 'string' },
413        variant: { type: 'string', description: 'rsID or HGVS protein/cDNA change' },
414        sort: { type: 'string', enum: ['relevance', 'date', 'cited'] },
415        abstract: { type: 'array', items: { type: 'string' }, description: 'PMIDs whose abstracts to return' },
416        limit: { type: 'number', default: 15 },
417      },
418    },
419    argv: i => [
420      'lit', ...(str(i.query) ? [str(i.query) as string] : []),
421      ...flag('--gene', str(i.gene)), ...flag('--variant', str(i.variant)), ...flag('--sort', str(i.sort)),
422      ...(list(i.abstract).length ? ['--abstract', ...list(i.abstract)] : []), ...flag('--limit', num(i.limit)),
423    ],
424    deferred: true,
425  },
426  {
427    name: 'rare_stats',
428    description:
429      'Classical statistics for rare-disease genetics (no network): segregation (cosegregation LR and PP1 strength), maxaf (Whiffin maximum credible allele frequency), carrier (Hardy–Weinberg carrier frequency from prevalence or allele frequencies), recurrence, fisher, burden (case/control collapsing test), denovo (de novo enrichment, Poisson), km (Kaplan–Meier from a CSV), nof1 (N-of-1 trial design or analysis). params are the CLI flags of `zebra stats <method>` without dashes.',
430    inputSchema: {
431      type: 'object',
432      properties: {
433        method: { type: 'string', enum: ['segregation', 'maxaf', 'carrier', 'recurrence', 'fisher', 'burden', 'denovo', 'km', 'nof1'] },
434        params: { type: 'object', additionalProperties: true },
435      },
436      required: ['method'],
437    },
438    argv: i => {
439      const method = str(i.method) ?? ''
440      const allowed = STATS_FLAGS[method]
441      if (!allowed) throw new Error(`unknown method ${JSON.stringify(method)}`)
442      const out = ['stats', method]
443      const params = (i.params && typeof i.params === 'object' ? i.params : {}) as Record<string, unknown>
444      for (const [k, v] of Object.entries(params)) {
445        const flag = k.replace(/_/g, '-')
446        if (!allowed.includes(flag)) {
447          throw new Error(`${method} does not take ${JSON.stringify(k)}; it takes ${allowed.join(', ')}`)
448        }
449        const key = `--${flag}`
450        if (Array.isArray(v)) out.push(key, ...v.map(String))
451        else if (typeof v === 'boolean') {
452          if (v) out.push(key)
453        } else if (v !== null && v !== undefined) out.push(key, String(v))
454      }
455      return out
456    },
457    deferred: true,
458  },
459  {
460    name: 'edit_check',
461    description:
462      'Base-editing feasibility screen for an SNV: can an adenine or cytosine base editor revert the patient allele to reference, and which SpCas9 protospacers (PAM NGG, or NG for relaxed-PAM variants) put the base in the editing window, with bystander bases (annotate_bystanders checks their coding effect via VEP). Reference sequence from Ensembl. A screen for researchers, not a guide design; delivery to the tissue is the hard part.',
463    inputSchema: {
464      type: 'object',
465      properties: {
466        variant: { type: 'string', description: 'chrom-pos-ref-alt (SNV), transcript HGVS or rsID' },
467        assembly: ASSEMBLY,
468        pam: { type: 'string', enum: ['NGG', 'NG'], description: 'NG when no NGG protospacer exists' },
469        window: { type: 'string', description: 'editing window in protospacer positions, default "4-8"' },
470        annotate_bystanders: { type: 'boolean' },
471      },
472      required: ['variant'],
473    },
474    argv: i => [
475      'edit', str(i.variant) ?? '',
476      ...flag('--assembly', str(i.assembly)), ...flag('--pam', str(i.pam)), ...flag('--window', str(i.window)),
477      ...(i.annotate_bystanders === true ? ['--annotate-bystanders'] : []),
478    ],
479    deferred: true,
480  },
481  {
482    name: 'cnv_interpret',
483    description:
484      'Read the report forms that are not a single sequence variant. A CNV or microarray result (region or ISCN, with build, mosaicism such as x1~2 or 30%, de novo/maternal/paternal) → the genes it spans, every one checked against ClinGen gene dosage, and the ClinGen curated regions it overlaps (22q11.2, 1p36, Williams, PWS/AS, 16p11.2 …) with HI/TS scores, coverage and the ACMG/ClinGen 2020 section-2 row they point to; section 3 by gene count for losses and gains separately — inputs, never a classification (sections 4-5 need the case). An exon-level deletion or duplication (DMD exon 45-50 deletion, NM_004006.3:c.6439-?_7309+?del, intronic breakpoints) → exons, coding bases, whether the frame is kept, and — only when it is not — which additional exon skip restores it. SMN1/SMN2 copy number (clinical exon numbering 1, 2a, 2b, 3-8) read with the SMN2 count, framed as population data, not a prognosis. Repeat expansions (FMR1, HTT, DMPK, FXN, C9orf72) placed in their published size bands with sex-specific wording and what else is needed (methylation, AGG interruptions, the other allele) — a referenced reading, not a classification. Coordinates from Ensembl.',
485    inputSchema: {
486      type: 'object',
487      properties: {
488        result: {
489          type: 'string',
490          description: 'the finding as the report gives it: "chr15:23123715-28193120 loss" | "arr[GRCh38] 22q11.21(18648855_21800471)x1" | "DMD exon 45-50 deletion" | "NM_004006.3:c.6439-?_7309+?del" | "SMN1 exon 7 copy number 0" | "FMR1 CGG 230"',
491        },
492        assembly: ASSEMBLY,
493        gene: { type: 'string', description: 'when the string does not name one' },
494        copies: { type: 'number', description: 'copy number the report gives, when it is not in the string' },
495        inheritance: { type: 'string', enum: ['de_novo', 'maternal', 'paternal', 'biparental', 'unknown'] },
496        method: { type: 'string', description: 'CMA, CNV-seq, MLPA, ddPCR, repeat-primed PCR …' },
497        record: { type: 'boolean', description: 'also record it in the active case (a write: asked about like any case change)' },
498        sex: { type: 'string', enum: ['male', 'female'], description: 'decides what an X/Y copy number means (default: the case profile)' },
499        smn2_copies: { type: 'number', description: 'SMN2 copy number, read together with SMN1' },
500        related: { type: 'array', items: { type: 'string' }, description: 'other findings in the same report (the second allele of a repeat, a second CNV)' },
501      },
502      required: ['result'],
503    },
504    argv: i => [
505      'cnv', str(i.result) ?? '',
506      ...flag('--assembly', str(i.assembly)), ...flag('--gene', str(i.gene)), ...flag('--copies', num(i.copies)),
507      ...flag('--inheritance', str(i.inheritance)), ...flag('--method', str(i.method)),
508      ...flag('--sex', str(i.sex)), ...flag('--smn2-copies', num(i.smn2_copies)),
509      ...(list(i.related).length ? ['--related', ...list(i.related)] : []),
510      ...(i.record === true ? ['--record'] : []),
511    ],
512    touchesCase: true,
513    timeoutMs: 180_000,
514  },
515  {
516    name: 'china_rare',
517    description:
518      "China's national rare disease lists (第一批罕见病目录 2018, 第二批 2023): is a disease on them, its Chinese name and list number. status: on_list (the published entry, or a named member of a listed group); qualified (only a subtype or form is listed — the result says which); possible (closest entries to verify, e.g. an acronym or a shared stretch of text — not a match); not_found. With query \"hospitals\": the 国家罕见病诊疗协作网 hospitals (NHC 2024 list, 419), by province with the lead hospitals. Input: Chinese or English name.",
519    inputSchema: {
520      type: 'object',
521      properties: {
522        query: { type: 'string', description: 'a disease name, or "hospitals" for the collaboration-network hospitals' },
523        province: { type: 'string', description: 'with "hospitals": e.g. 浙江 or 浙江省' },
524      },
525      required: ['query'],
526    },
527    argv: i => ['china', str(i.query) ?? '', ...flag('--province', str(i.province))],
528    deferred: true,
529  },
530  {
531    name: 'case_recheck',
532    description:
533      'Ask the active case\'s questions again and say what changed since the last recheck: ClinVar classification and stars of each recorded sequence variant, gnomAD frequency, ClinGen gene-disease validity of its genes, recruiting trials (new and no longer recruiting) and papers first published since then for each open hypothesis. The first run records the baseline. Each change says what to look at again (an ACMG reading, a trial\'s eligibility); nothing is concluded. Queries carry biology only and skip anything holding a protected identifier. plan: true lists the questions without sending them.',
534    inputSchema: { type: 'object', properties: { plan: { type: 'boolean' } } },
535    argv: (i, casePath) => {
536      if (!casePath) throw new Error('no active case (the person can run /zebra new <dir> or /zebra case <dir>)')
537      return ['case', 'recheck', casePath, ...(i.plan === true ? ['--plan'] : [])]
538    },
539    touchesCase: true,
540    deferred: true,
541    timeoutMs: 600_000,
542  },
543  {
544    name: 'access',
545    description:
546      'Access to a treatment, for a drug, a disease or a gene, with the source of every line: FDA and EMA status from the agencies\' own records (openFDA labels, Drugs@FDA, EMA medicine data — withdrawn and refused shown as such), approval in China from the bundled official NMPA/CDE documents (approved_in_china, named_not_approved, or not_in_bundled_list — which is not "not approved"), China\'s 2025 national reimbursement list (NRDL) entry with its restriction text verbatim, trials with sites in China (ClinicalTrials.gov; ChiCTR cannot be queried by a script — it blocks them), and the collaboration-network hospitals for a province. A name it cannot resolve exactly comes back unresolved with candidates; it never answers for a near match.',
547    inputSchema: {
548      type: 'object',
549      properties: {
550        query: { type: 'string', description: 'drug (INN or Chinese name), disease (Chinese or English) or gene symbol' },
551        as: { type: 'string', enum: ['auto', 'drug', 'disease', 'gene'] },
552        province: { type: 'string', description: 'list the collaboration-network hospitals of this province, e.g. 浙江' },
553        trials: { type: 'number', description: 'how many trials with a site in China (default a few)' },
554        status: { type: 'string', description: 'trial status filter, e.g. RECRUITING or ANY' },
555      },
556      required: ['query'],
557    },
558    argv: i => [
559      'access', str(i.query) ?? '', ...flag('--as', str(i.as)), ...flag('--province', str(i.province)),
560      ...flag('--trials', num(i.trials)), ...flag('--status', str(i.status)),
561    ],
562    timeoutMs: 180_000,
563  },
564  {
565    name: 'expression',
566    description:
567      'Median expression of a gene per tissue (GTEx v8/v10) with each tissue\'s ontology id (UBERON/EFO) — for choosing the AlphaGenome tissue and the tissue an RNA test can use: the result states whether blood, lymphoblastoid cells, fibroblasts, skin or muscle reach 1 TPM. Bulk adult medians, not a detection limit; an NMD-degraded transcript reads low.',
568    inputSchema: {
569      type: 'object',
570      properties: {
571        gene: { type: 'string' },
572        top: { type: 'number' },
573        dataset: { type: 'string', enum: ['gtex_v8', 'gtex_v10'] },
574      },
575      required: ['gene'],
576    },
577    argv: i => ['expression', str(i.gene) ?? '', ...flag('--top', num(i.top)), ...flag('--dataset', str(i.dataset))],
578    deferred: true,
579  },
580  {
581    name: 'aso_screen',
582    description:
583      'Splice-switching antisense feasibility screen for researchers: from SpliceAI for the variant, the aberrant event (pseudoexon / cryptic acceptor / cryptic donor, boundaries checked for AG/GT in the patient sequence; or exon_skip to restore a reading frame), then candidate target windows on the pre-mRNA with coordinates, target and antisense sequence, GC, hairpin and homopolymer flags, whether the variant lies inside, every ranking component shown, and published N-of-1 precedents retrieved from Europe PMC. When no aberrant splicing is predicted it says so and stops. Optional genomic uniqueness via NCBI BLAST (slow: 30-700 s). A screen, not a design: no chemistry, dose or delivery; RNA evidence of the aberrant splicing comes first.',
584    inputSchema: {
585      type: 'object',
586      properties: {
587        variant: { type: 'string', description: 'chrom-pos-ref-alt, transcript HGVS or rsID' },
588        assembly: ASSEMBLY,
589        event: { type: 'string', enum: ['auto', 'pseudoexon', 'cryptic_acceptor', 'cryptic_donor', 'exon_skip'] },
590        lengths: { type: 'string', description: 'target lengths LO-HI between 12 and 40 (default 18-25)' },
591        min_delta: { type: 'number', description: 'SpliceAI delta threshold (default 0.2)' },
592        top: { type: 'number', description: '1-60 (default 12)' },
593        uniqueness: { type: 'boolean', description: 'one NCBI BLAST search of the shortlist; adds 30-700 s' },
594        distance: { type: 'number' },
595      },
596      required: ['variant'],
597    },
598    argv: i => [
599      'aso', str(i.variant) ?? '', ...flag('--assembly', str(i.assembly)), ...flag('--event', str(i.event)),
600      ...flag('--lengths', str(i.lengths)), ...flag('--min-delta', num(i.min_delta)), ...flag('--top', num(i.top)),
601      ...flag('--distance', num(i.distance)), ...(i.uniqueness === true ? ['--uniqueness'] : []),
602    ],
603    deferred: true,
604    timeoutMs: 600_000,
605  },
606  {
607    name: 'report_export',
608    description:
609      'Turn a Markdown report from the case (family letter, visit-preparation sheet, clinician summary) into Word (.docx) and PDF — and HTML — with Chinese typography, for a family that does not use a terminal: the person running zebra-mod hands them the files. Written next to the report. Refuses a report that still contains the case\'s protected identifiers or a resident ID number. PDF is printed by a browser on this machine (Chrome, Edge, Chromium, Brave) or LibreOffice; without one the result says so and the other formats are still made.',
610    inputSchema: {
611      type: 'object',
612      properties: {
613        report: { type: 'string', description: 'the .md report: an absolute path, or relative to the case folder (reports/family-letter-2026-10-06.md)' },
614        formats: { type: 'array', items: { type: 'string', enum: ['docx', 'pdf', 'html'] }, description: 'default docx and pdf' },
615        title: { type: 'string', description: 'document title (default: the first heading)' },
616      },
617      required: ['report'],
618    },
619    argv: (i, casePath) => {
620      const report = str(i.report) ?? ''
621      const path = report && casePath && !/^(?:\/|~)/.test(report) ? `${casePath.replace(/\/$/, '')}/${report}` : report
622      const formats = list(i.formats)
623      return ['report', 'export', path, ...flag('--to', formats.length ? formats.join(',') : undefined), ...flag('--title', str(i.title))]
624    },
625    deferred: true,
626    timeoutMs: 150_000,
627  },
628]
629
630/** The keys the tool's own schema declares: the event also carries `tool`, `tool_use_id` and more. */
631export function schemaArgs(def: ToolDef, input: Record<string, unknown>): Record<string, unknown> {
632  const props = (def.inputSchema.properties ?? {}) as Record<string, unknown>
633  const out: Record<string, unknown> = {}
634  for (const key of Object.keys(props)) if (input[key] !== undefined) out[key] = input[key]
635  return out
636}
637
638/** No value the model supplies legitimately starts with "-": the CLI would read it as a flag. */
639function flagShaped(value: unknown, path = ''): string | undefined {
640  if (typeof value === 'string') {
641    return value.trimStart().startsWith('-') ? `${path || 'value'}: ${JSON.stringify(value)}` : undefined
642  }
643  if (Array.isArray(value)) {
644    for (let i = 0; i < value.length; i++) {
645      const hit = flagShaped(value[i], `${path}[${i}]`)
646      if (hit) return hit
647    }
648    return undefined
649  }
650  if (value && typeof value === 'object') {
651    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
652      const hit = flagShaped(v, path ? `${path}.${k}` : k)
653      if (hit) return hit
654    }
655  }
656  return undefined
657}
658
659export async function toolArgv(def: ToolDef, input: Record<string, unknown>, casePath: string | null): Promise<string[]> {
660  const clean = schemaArgs(def, input)
661  // `case_update` carries the person's own prose (notes, questions), where a leading
662  // dash is harmless: it is passed as one JSON argument, never as a command-line value.
663  if (def.name !== 'case_update') {
664    const shaped = flagShaped(clean)
665    if (shaped) throw new Error(`${shaped} is not a value this tool takes (it reads as a command-line flag)`)
666  }
667  const argv = def.argv(clean, casePath)
668  if (argv.some(a => a === '')) throw new Error('a required argument is empty')
669  return argv
670}
671
hooks/ui.tsx 623 lines
1// What zebra-mod shows in Claude Code, so a person can see when it is at work and what it did:
2//   - its own rows for its tool calls (what is looked up, in which databases, how much evidence came back)
3//     in place of a raw JSON envelope; a case_update row never shows the identifiers it registers
4//   - the spinner says which databases are being queried
5//   - a galloping zebra in the band above the prompt while a query runs, and a standing one with a
6//     shield for a few seconds after the privacy gate stopped an outgoing call
7//   - a footer label (🦓 and the open case) so it is always clear the mod is installed
8//   - one line at the end of a turn that used it: calls, databases, evidence rows, calls refused
9// Everything is additive and scoped to zebra-mod's own activity: unrelated work draws as before.
10// These hooks are registered first, so they wrap the tool, gate and drawing hooks in register.tsx.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
14
15import type { Board, ZebraRun, ZebraTurn } from '../types'
16import { FRAME_COUNT, RASTER_COLUMNS, RASTER_ROWS, zebraCells } from './ui-sprite'
17
18type On = Parameters<Register>[0]
19type Options = Parameters<Register>[1]
20export type UiMode = 'full' | 'quiet' | 'off'
21
22const PREFIX = 'mcp__zebra-mod__'
23const LOCAL = 'local'
24const FRAME_MS = 70 // eleven frames: one stride in about 0.8 s
25
26const EMPTY_TURN: ZebraTurn = { calls: 0, failed: 0, blocked: 0, sources: [], cached: 0, evidence: 0, firstEvidence: null, lastEvidence: null }
27
28const board = atom({ plugin: 'zebra-mod', key: 'board' } as const, null as Board | null)
29const running = atom({ plugin: 'zebra-mod', key: 'running' } as const, [] as ZebraRun[])
30const turnStats = atom({ plugin: 'zebra-mod', key: 'turn' } as const, EMPTY_TURN)
31const blockedAt = atom({ plugin: 'zebra-mod', key: 'blockedAt' } as const, null as number | null)
32const lang = atom({ plugin: 'zebra-mod', key: 'lang' } as const, null as string | null)
33// why the gate refused: an identifier in the call, or a case file it could not read (the gate closed)
34// true from the first interactive session after installing until the person's first prompt
35const onboarding = atom({ plugin: 'zebra-mod', key: 'onboarding' } as const, false as boolean)
36const blockedWhy = atom({ plugin: 'zebra-mod', key: 'blockedWhy' } as const, 'identifier' as string)
37
38// ------------------------------------------------------------ words
39
40// [Chinese, English]
41const LABELS: Record<string, [string, string]> = {
42  case_status: ['病例概况', 'Case status'],
43  case_update: ['更新病例', 'Case update'],
44  case_recheck: ['病例复查', 'Case recheck'],
45  hpo_search: ['HPO 术语检索', 'HPO term search'],
46  phenotype_rank: ['表型排序', 'Phenotype ranking'],
47  gene_card: ['基因卡', 'Gene card'],
48  variant_card: ['变异卡', 'Variant card'],
49  disease_card: ['疾病卡', 'Disease card'],
50  acmg: ['ACMG 计分', 'ACMG points'],
51  cnv_interpret: ['拷贝数变异解读', 'CNV reading'],
52  s2f_predict: ['序列功能预测', 'Sequence-to-function'],
53  therapy_landscape: ['治疗全景', 'Therapy landscape'],
54  trials_search: ['临床试验检索', 'Trial search'],
55  literature_search: ['文献检索', 'Literature search'],
56  rare_stats: ['统计计算', 'Statistics'],
57  edit_check: ['碱基编辑可行性', 'Base-editing screen'],
58  china_rare: ['中国罕见病目录', 'China rare disease lists'],
59  access: ['药物可及性', 'Drug access'],
60  expression: ['组织表达', 'Tissue expression'],
61  aso_screen: ['反义寡核苷酸筛查', 'Antisense screen'],
62  report_export: ['导出报告', 'Report export'],
63}
64
65// What each tool is expected to query, shown while it runs; the row afterwards names what it did query.
66const EXPECTED: Record<string, string[]> = {
67  case_status: [LOCAL], case_update: [LOCAL], report_export: [LOCAL], rare_stats: [LOCAL],
68  case_recheck: ['ClinVar', 'ClinGen', 'ClinicalTrials.gov', 'Europe PMC'],
69  hpo_search: ['HPO'],
70  phenotype_rank: ['HPO', 'Monarch', 'PubCaseFinder'],
71  gene_card: ['HGNC', 'ClinGen', 'gnomAD', 'PanelApp', 'UniProt'],
72  variant_card: ['Ensembl VEP', 'gnomAD', 'ClinVar', 'LitVar2'],
73  disease_card: ['Orphanet', 'Monarch', 'GeneReviews', 'HPO'],
74  cnv_interpret: ['Ensembl', 'ClinGen'],
75  s2f_predict: ['SpliceAI', 'Pangolin'],
76  therapy_landscape: ['Open Targets', 'EMA'],
77  trials_search: ['ClinicalTrials.gov'],
78  literature_search: ['Europe PMC', 'PubTator3', 'LitVar2'],
79  edit_check: ['Ensembl'],
80  china_rare: ['China national rare disease list'],
81  access: ['Open Targets', 'EMA', 'China drug approvals', '国家医保药品目录', 'ClinicalTrials.gov'],
82  expression: ['GTEx'],
83  aso_screen: ['Ensembl', 'SpliceAI', 'Europe PMC'],
84}
85
86// Source names whose short form is not their first words.
87const RENAMED: Array<[RegExp, string, string]> = [
88  [/^China drug approvals/, 'NMPA/CDE', 'NMPA/CDE'],
89  [/^China national rare disease list/, '国家罕见病目录', 'China rare disease lists'],
90  [/^China rare disease list alias/, '', ''],
91  [/^国家医保药品目录/, '国家医保目录', 'China NRDL'],
92  [/^全国罕见病诊疗协作网/, '罕见病诊疗协作网', 'China rare disease network'],
93  [/^GeneReviews/, 'GeneReviews', 'GeneReviews'],
94  [/^local$/, '本机', 'this computer'],
95]
96
97/** "ClinVar esearch" → ClinVar, "HPO annotations (phenotype.hpoa)" → HPO: the database, not the endpoint. */
98export function sourceName(db: string, zh: boolean): string {
99  for (const [re, z, en] of RENAMED) if (re.test(db)) return zh ? z : en
100  const head = (db.split(' (')[0] ?? db).trim()
101  const kept: string[] = []
102  for (const word of head.split(/\s+/)) {
103    if (kept.length > 0 && /^[a-z]/.test(word)) break
104    kept.push(word)
105  }
106  return kept.join(' ') || head
107}
108
109function sourceNames(dbs: readonly string[], zh: boolean): string[] {
110  return [...new Set(dbs.map(d => sourceName(d, zh)).filter(Boolean))]
111}
112
113export function labelOf(tool: string, zh: boolean): string {
114  const l = LABELS[tool]
115  return l ? (zh ? l[0] : l[1]) : tool
116}
117
118function clip(text: string, max: number): string {
119  const one = text.replace(/\s+/g, ' ').trim()
120  return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
121}
122
123/** What a call is about, for its row. Never the identifiers a case_update registers. */
124export function queryOf(tool: string, input: unknown, zh: boolean): string {
125  const i = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
126  const s = (k: string): string => (typeof i[k] === 'string' ? (i[k] as string).trim() : '')
127  const n = (k: string): number => (Array.isArray(i[k]) ? (i[k] as unknown[]).length : 0)
128  // the first plain argument, when the usual ones are absent (never the reserved keys or identifiers)
129  const any = (): string => {
130    for (const [k, v] of Object.entries(i)) {
131      if (['tool', 'tool_use_id', 'consent', 'agentId', 'identifiers'].includes(k)) continue
132      if (typeof v === 'string' && v.trim()) return v.trim()
133      if (Array.isArray(v) && v.length && v.every(x => typeof x === 'string')) return (v as string[]).join(' ')
134    }
135    return ''
136  }
137  return pick() || (tool === 'case_update' || tool === 'case_status' || tool === 'case_recheck' ? '' : any())
138  function pick(): string {
139  switch (tool) {
140    case 'case_update': {
141      const parts: string[] = []
142      const ids = n('identifiers')
143      if (ids) parts.push(zh ? `登记受保护身份信息 ${ids} 项(内容不显示)` : `${ids} protected identifiers registered (not shown)`)
144      const kinds: Array<[string, string, string]> = [
145        ['phenotypes', '表型', 'phenotypes'], ['variants', '变异', 'variants'], ['hypotheses', '诊断假设', 'hypotheses'],
146        ['acmg', 'ACMG 判读', 'ACMG readings'], ['leads', '治疗线索', 'therapy leads'], ['tests', '已做检查', 'tests'],
147        ['family', '家庭成员', 'relatives'], ['timeline', '时间线', 'timeline events'], ['questions', '待问问题', 'questions'],
148      ]
149      for (const [k, z, en] of kinds) {
150        const c = n(k)
151        if (c) parts.push(zh ? `${z} ${c} 项` : `${c} ${en}`)
152      }
153      if (i.profile && typeof i.profile === 'object') parts.push(zh ? '基本信息' : 'profile')
154      if (n('remove')) parts.push(zh ? `删除 ${n('remove')} 项` : `${n('remove')} removals`)
155      return parts.join(' · ')
156    }
157    case 'case_status':
158    case 'case_recheck':
159      return ''
160    case 'hpo_search':
161      return s('text')
162    case 'phenotype_rank': {
163      if (i.from_case === true) return zh ? '使用病例中的表型' : 'phenotypes from the case'
164      const p = n('present')
165      const x = n('excluded')
166      return zh ? `${p} 项表型${x ? ` · 排除 ${x} 项` : ''}` : `${p} present${x ? ` · ${x} excluded` : ''}`
167    }
168    case 'gene_card':
169      return s('symbol')
170    case 'acmg': {
171      const codes = Array.isArray(i.codes) ? (i.codes as unknown[]).filter((c): c is string => typeof c === 'string') : []
172      return codes.length ? codes.join(' + ') : s('variant')
173    }
174    case 'trials_search':
175      return [s('condition'), s('term'), s('country')].filter(Boolean).join(' · ')
176    case 'literature_search':
177      return s('query') || [s('gene'), s('variant')].filter(Boolean).join(' ')
178    case 'rare_stats':
179      return s('method')
180    case 'cnv_interpret':
181      return s('result')
182    case 'report_export':
183      return s('report')
184    default: {
185      for (const k of ['variant', 'query', 'gene', 'symbol', 'text']) if (s(k)) return s(k)
186      return ''
187    }
188  }
189  }
190}
191
192// ------------------------------------------------------------ the tool's answer
193
194type Envelope = { warnings?: unknown; ledger?: unknown; sources?: unknown; result?: unknown }
195
196/** The JSON envelope register.tsx answers a zebra tool with, from a ToolResult's output, or null. */
197export function envelopeOf(output: unknown): Envelope | null {
198  let text: string | undefined
199  if (typeof output === 'string') text = output
200  else if (Array.isArray(output)) {
201    text = output.map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : '')).join('')
202  } else if (output && typeof output === 'object') {
203    if ('ledger' in output || 'sources' in output) return output as Envelope
204    const t = (output as { text?: unknown }).text
205    if (typeof t === 'string') text = t
206  }
207  if (!text || text[0] !== '{') return null
208  try {
209    const parsed = JSON.parse(text) as unknown
210    return parsed && typeof parsed === 'object' && ('ledger' in parsed || 'sources' in parsed) ? (parsed as Envelope) : null
211  } catch {
212    return null
213  }
214}
215
216type Digest = { dbs: string[]; cached: number; evidence: number; first: number | null; last: number | null; warnings: number }
217
218export function digest(env: Envelope): Digest {
219  const sources = Array.isArray(env.sources) ? env.sources : []
220  const dbs: string[] = []
221  let cached = 0
222  for (const s of sources) {
223    if (!s || typeof s !== 'object') continue
224    const db = (s as { db?: unknown }).db
225    if (typeof db === 'string' && db.trim()) dbs.push(db.trim())
226    if ((s as { cached?: unknown }).cached === true) cached++
227  }
228  const ids = (Array.isArray(env.ledger) ? env.ledger : [])
229    .map(x => (typeof x === 'string' ? /^E(\d+)$/.exec(x.trim()) : null))
230    .filter((m): m is RegExpExecArray => m !== null)
231    .map(m => Number(m[1]))
232  const warnings = Array.isArray(env.warnings) ? env.warnings.length : 0
233  return {
234    dbs: [...new Set(dbs)],
235    cached,
236    evidence: ids.length,
237    first: ids.length ? Math.min(...ids) : null,
238    last: ids.length ? Math.max(...ids) : null,
239    warnings,
240  }
241}
242
243function range(first: number | null, last: number | null): string {
244  if (first === null || last === null) return ''
245  return first === last ? `E${first}` : `E${first}–E${last}`
246}
247
248function listed(names: string[], zh: boolean, max = 4): string {
249  if (names.length <= max) return names.join(zh ? '、' : ', ')
250  const shown = names.slice(0, max).join(zh ? '、' : ', ')
251  return zh ? `${shown} 等 ${names.length} 个` : `${shown} +${names.length - max} more`
252}
253
254/** The one line under a zebra tool's row: evidence, databases, cache, notes. */
255export function summaryLine(env: Envelope, zh: boolean): string {
256  const d = digest(env)
257  const names = sourceNames(d.dbs, zh)
258  const parts: string[] = []
259  if (d.evidence) parts.push(zh ? `证据 ${d.evidence} 条(${range(d.first, d.last)})` : `${d.evidence} evidence rows (${range(d.first, d.last)})`)
260  if (names.length) parts.push(zh ? `来源 ${listed(names, zh)}` : `from ${listed(names, zh)}`)
261  if (d.cached) parts.push(zh ? `${d.cached} 条来自缓存` : `${d.cached} cached`)
262  if (d.warnings) parts.push(zh ? `${d.warnings} 条提示` : `${d.warnings} notes`)
263  if (!parts.length) return zh ? '已在本机完成' : 'done on this computer'
264  return parts.join(' · ')
265}
266
267/** The line left at the end of a turn that used zebra-mod. */
268export function turnLine(t: ZebraTurn, zh: boolean): string | undefined {
269  if (t.calls === 0 && t.blocked === 0) return undefined
270  if (t.calls === 0) {
271    return zh
272      ? `🛡 本轮隐私闸门拦下 ${t.blocked} 次外发调用,相关内容未发出。`
273      : `🛡 The privacy gate stopped ${t.blocked} outgoing call${t.blocked > 1 ? 's' : ''} this turn; nothing in them was sent.`
274  }
275  const names = sourceNames(t.sources, zh).filter(n => n !== (zh ? '本机' : 'this computer'))
276  const parts: string[] = [zh ? `调用 ${t.calls} 次` : `${t.calls} call${t.calls > 1 ? 's' : ''}`]
277  if (names.length) parts.push(zh ? `查询 ${names.length} 个数据源(${listed(names, zh, 5)})` : `${names.length} databases (${listed(names, zh, 5)})`)
278  if (t.evidence) parts.push(zh ? `新增证据 ${t.evidence} 条(${range(t.firstEvidence, t.lastEvidence)})` : `${t.evidence} evidence rows (${range(t.firstEvidence, t.lastEvidence)})`)
279  if (t.failed) parts.push(zh ? `${t.failed} 次未执行` : `${t.failed} not run`)
280  if (t.blocked) parts.push(zh ? `隐私闸门拦截 ${t.blocked} 次` : `${t.blocked} stopped by the privacy gate`)
281  return `🦓 ${zh ? '本轮 zebra-mod:' : 'zebra-mod this turn: '}${parts.join(' · ')}`
282}
283
284// ------------------------------------------------------------ hooks
285
286// What the animation and the language choice read between render passes; a reload resets them.
287let envZh: boolean | undefined
288let bandId: string | undefined
289let frame = 0
290let scroll = 0
291let ticker: { cancel: () => void; owner: string } | undefined
292
293async function isZh($: EngineInterface): Promise<boolean> {
294  const b = await read($, board)
295  if (b?.language) return b.language.toLowerCase().startsWith('zh')
296  const l = await read($, lang)
297  if (l) return l === 'zh'
298  if (envZh === undefined) {
299    const env = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
300    envZh = env.toLowerCase().startsWith('zh')
301  }
302  return envZh
303}
304
305// The gallop runs inside the tool call that started it (that dispatch lasts as long as the query)
306// and stops with it.
307function startTicker($: EngineInterface, owner: string): void {
308  if (ticker) return
309  let timer: { cancel: () => void }
310  try {
311    timer = $.clock.every(FRAME_MS, () => {
312    frame = (frame + 1) % FRAME_COUNT
313    scroll += 2
314    if (bandId === undefined) return
315    void $.ui
316      .blit({ requestId: bandId, key: 'zebra', cells: zebraCells(frame, scroll), columns: RASTER_COLUMNS, rows: RASTER_ROWS })
317      .catch(() => undefined)
318    })
319  } catch {
320    return // no timer here: the band stays a still picture
321  }
322  ticker = { cancel: () => timer.cancel(), owner }
323}
324
325function stopTicker(owner: string): void {
326  if (ticker?.owner !== owner) return
327  ticker.cancel()
328  ticker = undefined
329}
330
331/** One zebra tool call, wrapped: in flight for the spinner and the band, then tallied for the turn. */
332async function observeCall<E extends { tool_use_id?: string }>(
333  $: EngineInterface,
334  e: E,
335  next: (e: E) => Promise<ToolCallResult>,
336  tool: string,
337  mode: UiMode,
338): Promise<ToolCallResult> {
339  const now = Date.now()
340  const id = String(e.tool_use_id ?? `${tool}:${now}`)
341  const run: ZebraRun = {
342    id,
343    tool,
344    queryZh: clip(queryOf(tool, e, true), 80),
345    queryEn: clip(queryOf(tool, e, false), 80),
346    sources: EXPECTED[tool] ?? [],
347    startedAt: now,
348  }
349  await update($, running, list => [...list, run])
350  if (mode === 'full' && run.sources.some(s => s !== LOCAL)) startTicker($, id)
351  let answer: ToolCallResult | undefined
352  try {
353    answer = await next(e)
354    return answer
355  } finally {
356    stopTicker(id)
357    await update($, running, list => list.filter(r => r.id !== id))
358    await update($, turnStats, t => recordCall(t, tool, answer))
359  }
360}
361
362/** A tool-result row that carries the privacy gate's refusal: a shield until the next turn. */
363async function noteRefusals($: EngineInterface, content: unknown, mode: UiMode): Promise<void> {
364  const texts = gateRefusals(content)
365  const refusals = texts.length
366  if (refusals === 0) return
367  const at = Date.now()
368  await update($, blockedWhy, () => (texts.some(t => t.includes('gate is closed')) ? 'closed' : 'identifier'))
369  await update($, blockedAt, () => at)
370  await update($, turnStats, t => ({ ...t, blocked: t.blocked + refusals }))
371  if (mode === 'quiet') {
372    $.ui.toast(
373      (await isZh($))
374        ? '🛡 隐私闸门拦下一次外发调用:其中含有受保护的身份信息,未发出。'
375        : '🛡 The privacy gate stopped an outgoing call that carried a protected identifier; it was not sent.',
376    )
377  }
378}
379
380/** The first interactive session after installing (or updating to this version): say what to do. */
381async function startOnboarding($: EngineInterface): Promise<void> {
382  if ((await $.store.get('onboarded')) === true) return
383  await update($, onboarding, () => true)
384  $.ui.toast(
385    (await isZh($))
386      ? '🦓 zebra-mod 已就绪:直接用中文描述病情或检查结果即可。输入 /zebra demo 打开示例病例试试,/zebra 查看全部功能。'
387      : '🦓 zebra-mod is ready: just describe symptoms or test results. Type /zebra demo to try a demo case, /zebra for everything it does.',
388  )
389}
390
391/** The first prompt ends the onboarding card, for good. */
392async function endOnboarding($: EngineInterface): Promise<void> {
393  if (!(await read($, onboarding))) return
394  await update($, onboarding, () => false)
395  await $.store.set('onboarded', true)
396}
397
398async function closeTurn($: EngineInterface): Promise<void> {
399  const line = turnLine(await read($, turnStats), await isZh($))
400  if (line) await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: line }] } }).catch(() => undefined)
401  await update($, turnStats, () => EMPTY_TURN)
402  await update($, running, () => [])
403}
404
405export function registerUi(on: On, options: Options): void {
406  const mode: UiMode = options.interface === 'quiet' || options.interface === 'off' ? options.interface : 'full'
407  if (mode === 'off') return
408
409  on('turn.start', async ($, e, next) => {
410    await update($, turnStats, () => EMPTY_TURN)
411    await update($, blockedAt, () => null)
412    await update($, running, () => []) // nothing of a previous turn is still running
413    if (/[㐀-鿿]/.test(e.text)) await update($, lang, () => 'zh')
414    await endOnboarding($)
415    return next(e)
416  })
417
418  on('session.start', { isInteractive: true }, async ($, e, next) => {
419    const started = await next(e)
420    await startOnboarding($)
421    return started
422  })
423
424  on('turn.complete', async ($, e, next) => {
425    const done = await next(e)
426    if (!e.agentId) await closeTurn($)
427    return done
428  })
429
430  // Wraps the tool hook in register.tsx (registered after these): what runs, and what it brought back.
431  for (const tool of Object.keys(LABELS)) {
432    on('tool.call', { tool: `${PREFIX}${tool}` }, async ($, e, next) => observeCall($, e, next, tool, mode)).catch(($, e, next) => next(e))
433  }
434
435  // The privacy gate's refusals, from the tool results they leave (any tool, zebra's or not).
436  on('session.append', async ($, e, next) => {
437    const stored = await next(e)
438    if (e.door === 'tool-result') await noteRefusals($, e.message.content, mode)
439    return stored
440  }).catch(($, e, next) => next(e))
441
442  // ---------------------------------------------------------- drawing
443
444  // A zebra tool's row: 🦓, what it is, what it is about; while it runs, the databases it queries.
445  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
446    const tool = e.props.tool.startsWith(PREFIX) ? e.props.tool.slice(PREFIX.length) : ''
447    if (!LABELS[tool] || e.props.isInterrupted) return next(e)
448    const zh = await isZh($)
449    const { Box, Text } = $.ui.resolve(e)
450    const width = e.viewport?.columns ?? 100
451    const label = labelOf(tool, zh)
452    const query = clip(queryOf(tool, e.props.input, zh), Math.max(16, width - label.length - 16))
453    const head = [<Text color="claude">🦓 </Text>, <Text bold>{label}</Text>]
454    if (query) head.push(<Text dimColor>{`  ${query}`}</Text>)
455    if (e.props.isRunning) head.push(<Text color="subtle">{zh ? '  查询中…' : '  running…'}</Text>)
456    else if (e.props.isErrored) head.push(<Text color="error">{zh ? '  未执行' : '  not run'}</Text>)
457    const rows = [<Box flexDirection="row">{head}</Box>]
458    const expected = sourceNames(EXPECTED[tool] ?? [], zh)
459    if (e.props.isRunning && expected.length) {
460      rows.push(<Text dimColor wrap="truncate-end">{`   ${zh ? '数据源:' : 'sources: '}${expected.join(' · ')}`}</Text>)
461    }
462    return <Box flexDirection="column">{rows}</Box>
463  })
464
465  // Its result: evidence, databases, cache and notes in one line, in place of the JSON envelope.
466  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
467    const tool = e.props.tool.startsWith(PREFIX) ? e.props.tool.slice(PREFIX.length) : ''
468    if (!LABELS[tool] || e.props.isErrored) return next(e)
469    const env = envelopeOf(e.props.output)
470    if (!env) return next(e)
471    const { Text } = $.ui.resolve(e)
472    return <Text dimColor wrap="truncate-end">{`  ⎿ ${summaryLine(env, await isZh($))}`}</Text>
473  })
474
475  // The spinner names the databases being queried.
476  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
477    if (mode !== 'full' || e.props.message) return next(e)
478    const runs = await read($, running)
479    if (!runs.length) return next(e)
480    const zh = await isZh($)
481    const names = sourceNames(runs.flatMap(r => r.sources), zh).filter(n => n !== (zh ? '本机' : 'this computer'))
482    const word = names.length
483      ? `🦓 ${zh ? '正在查询' : 'Querying'} ${listed(names, zh, 3)}`
484      : `🦓 ${labelOf((runs[0] as ZebraRun).tool, zh)}`
485    return next({ ...e, props: { ...e.props, word } })
486  })
487
488  // A footer label: the mod is here (🦓), with the open case's title, or a shield after a refusal.
489  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
490    const b = await read($, board)
491    const at = await read($, blockedAt)
492    const label = `${at !== null ? '🛡' : '🦓'} ${b ? caseLabel(b) : 'zebra'}`
493    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
494  })
495
496  // The band above the prompt: a galloping zebra while a query runs; a standing one with a shield
497  // after the gate refused a call, until the next turn. Nothing otherwise.
498  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
499    if (mode !== 'full' || e.props.hasSurvey) return next(e)
500    const runs = (await read($, running)).filter(r => r.sources.some(s => s !== LOCAL))
501    const at = await read($, blockedAt)
502    // the welcome card until the first prompt, and not once a case is open (the demo, a new case)
503    const isWelcome = !runs.length && at === null && (await read($, onboarding)) && (await read($, board)) === null
504    if (!runs.length && at === null && !isWelcome) return next(e)
505    const zh = await isZh($)
506    bandId = e.requestId
507    const lines = runs.length ? runningLines(runs, zh) : isWelcome ? welcomeLines(zh) : shieldLines(zh, (await read($, blockedWhy)) === 'closed')
508    const tone = runs.length || isWelcome ? 'claude' : 'success'
509    if (e.surface === 'terminal' && e.props.bodyColumns >= RASTER_COLUMNS + 30) {
510      const { Box, Text, Raster } = $.ui.resolve(e)
511      return (
512        <Box flexDirection="row" gap={2}>
513          <Raster key="zebra" columns={RASTER_COLUMNS} rows={RASTER_ROWS} cells={zebraCells(frame, scroll, runs.length === 0)} />
514          <Box flexDirection="column" justifyContent="center" width={Math.max(20, e.props.bodyColumns - RASTER_COLUMNS - 2)}>
515            <Text bold color={tone} wrap="truncate-end">{lines[0]}</Text>
516            <Text wrap="truncate-end">{lines[1]}</Text>
517            <Text dimColor wrap="truncate-end">{lines[2]}</Text>
518          </Box>
519        </Box>
520      )
521    }
522    const { Box, Text } = $.ui.resolve(e)
523    return (
524      <Box flexDirection="column">
525        <Text bold color={tone} wrap="truncate-end">{lines[0]}</Text>
526        <Text wrap="truncate-end">{lines[1]}</Text>
527        <Text dimColor wrap="truncate-end">{lines[2]}</Text>
528      </Box>
529    )
530  })
531}
532
533/** The open case in the footer: its title, phenotypes present, evidence rows. */
534export function caseLabel(b: Board): string {
535  const present = (b.phenotypes ?? []).filter(p => p.status === 'present').length
536  return `${clip(b.title, 20)} · HPO ${present} · E${b.evidence_count ?? 0}`
537}
538
539/** The band's three lines while queries run. */
540export function runningLines(runs: readonly ZebraRun[], zh: boolean): [string, string, string] {
541  const first = runs[0] as ZebraRun
542  const query = zh ? first.queryZh : first.queryEn
543  const what = runs.length === 1
544    ? `${labelOf(first.tool, zh)}${query ? `${zh ? ':' : ': '}${query}` : ''}`
545    : (zh ? `${runs.length} 项查询同时进行` : `${runs.length} queries at once`)
546  return [
547    zh ? '🦓 zebra-mod 正在查询公开数据库' : '🦓 zebra-mod is querying public databases',
548    what,
549    `${zh ? '数据源:' : 'sources: '}${sourceNames(runs.flatMap(r => r.sources), zh).join(' · ')}`,
550  ]
551}
552
553/** The band's three lines in the first session after installing, until the first prompt. */
554export function welcomeLines(zh: boolean): [string, string, string] {
555  return zh
556    ? [
557        '🦓 zebra-mod 已就绪 · 罕见病研究工作台',
558        '直接用中文描述症状、检查结果或基因报告,Claude 会调用它查证、计算并标出处。',
559        '先试一下:/zebra demo 打开示例病例 · /zebra 查看全部功能',
560      ]
561    : [
562        '🦓 zebra-mod is ready · rare-disease research workstation',
563        'Just describe symptoms, test results or a genetic report; Claude calls it to look things up, compute and cite.',
564        'Try it: /zebra demo opens a demo case · /zebra shows everything it does',
565      ]
566}
567
568/** The band's three lines after the privacy gate refused a call. */
569export function shieldLines(zh: boolean, isClosed = false): [string, string, string] {
570  if (isClosed) {
571    return [
572      zh ? '🛡 隐私闸门已关闭外发调用' : '🛡 The privacy gate is holding outgoing calls',
573      zh ? '病例的受保护身份信息名单无法读取,调用未发出。' : "The case's list of protected identifiers cannot be read; the call was not sent.",
574      zh ? '修复或重建 case.json,或用 /zebra close 关闭病例后再查询。' : 'Fix or re-create case.json, or /zebra close, then query again.',
575    ]
576  }
577  return [
578    zh ? '🛡 隐私闸门拦下了一次外发调用' : '🛡 The privacy gate stopped an outgoing call',
579    zh ? '调用中含有受保护的身份信息,内容未发出。' : 'It carried a protected identifier; it was not sent.',
580    zh ? '查询公开数据库时只使用 HPO 编号、基因、变异和疾病名称。' : 'Public databases are queried with HPO ids, genes, variants and disease names only.',
581  ]
582}
583
584/** The privacy gate's refusal texts among a tool-result row's blocks (register.tsx words them). */
585export function gateRefusals(content: unknown): string[] {
586  const out: string[] = []
587  const visit = (block: unknown): void => {
588    if (typeof block === 'string') {
589      if (block.startsWith('zebra-mod privacy gate:') && !block.includes('confirm.')) out.push(block)
590      return
591    }
592    if (Array.isArray(block)) return block.forEach(visit)
593    if (!block || typeof block !== 'object') return
594    const b = block as { type?: unknown; text?: unknown; content?: unknown; is_error?: unknown }
595    if (b.type === 'tool_result' && b.is_error !== true) return
596    if (typeof b.text === 'string') visit(b.text)
597    if (b.content !== undefined) visit(b.content)
598  }
599  visit(content)
600  return out
601}
602
603/** The turn's tally after one zebra call answered `answer`. */
604export function recordCall(t: ZebraTurn, tool: string, answer: ToolCallResult | undefined): ZebraTurn {
605  const next: ZebraTurn = { ...t, calls: t.calls + 1, sources: [...t.sources] }
606  if (!answer || 'deny' in answer && answer.deny !== undefined) {
607    next.failed++
608    return next
609  }
610  const env = envelopeOf(answer.result)
611  if (!env) {
612    if ((EXPECTED[tool] ?? []).includes(LOCAL) && !next.sources.includes(LOCAL)) next.sources.push(LOCAL)
613    return next
614  }
615  const d = digest(env)
616  for (const db of d.dbs) if (!next.sources.includes(db)) next.sources.push(db)
617  next.cached += d.cached
618  next.evidence += d.evidence
619  if (d.first !== null) next.firstEvidence = next.firstEvidence === null ? d.first : Math.min(next.firstEvidence, d.first)
620  if (d.last !== null) next.lastEvidence = next.lastEvidence === null ? d.last : Math.max(next.lastEvidence, d.last)
621  return next
622}
623
hooks/privacy.ts 547 lines
1// The privacy gate. Three jobs, each best effort and each said plainly where it is not:
2//   1. keep a case's registered identifiers out of anything that leaves the machine,
3//   2. notice the shell commands and tools that leave the machine at all,
4//   3. notice when a file from the case folder is about to be sent somewhere.
5// Matching happens on a normalised form, because a name reaches a web service
6// percent-encoded, '+'-joined, full-width or re-spaced far more often than verbatim.
7
8const ZERO_WIDTH = /[​-‏‪-‮⁠]/g
9const SEPARATORS = /[\s\-_/.,·'"()[\]{}]+/g
10
11/** Percent-decode, '+'-decode, \uXXXX-decode, NFKC-fold, strip zero-width, lowercase. */
12function fold(text: string): string {
13  let out = text
14  for (let i = 0; i < 3; i++) {
15    const before = out
16    if (/%[0-9a-fA-F]{2}/.test(out)) {
17      try {
18        out = decodeURIComponent(out.replace(/\+/g, ' '))
19      } catch {
20        out = out.replace(/%([0-9a-fA-F]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))).replace(/\+/g, ' ')
21      }
22    }
23    if (/\\u[0-9a-fA-F]{4}/.test(out)) {
24      out = out.replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)))
25    }
26    // HTML: a page can spell a name as entities (&#24352;) or split it with tags (<b>张</b>小明)
27    if (/&#?\w+;/.test(out)) {
28      out = out.replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
29        .replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
30        .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_m, n) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' } as Record<string, string>)[n] ?? _m)
31    }
32    if (/<\/?[a-z][^<>]{0,200}>/i.test(out)) out = out.replace(/<\/?[a-z][^<>]{0,200}>/gi, '')
33    if (out === before) break
34  }
35  // tone marks (Zhāng Xiǎomíng) and other diacritics never hide a name
36  return out.normalize('NFKD').replace(/\p{M}+/gu, '').normalize('NFKC').replace(ZERO_WIDTH, '').toLowerCase()
37}
38
39// Identifiers of science, not of people: their digits must never be read as a record number
40// (HP:0012345 shares its digits with a record number MZ0012345).
41const SCIENTIFIC_IDS = new RegExp([
42  String.raw`\b(?:hp|omim|mim|orpha|orphanet|mondo|doid|ncit|efo|uberon|go|hgnc|cl|chebi|mp)\s*[:_]\s*\d+`,
43  String.raw`\brs\d+`,
44  String.raw`\b(?:nm|nr|np|nc|ng|nt|xm|xp|enst|ensg|ensp|ccds|lrg)_?\d+(?:\.\d+)?`,
45  String.raw`\bpmid\s*:?\s*\d+`, String.raw`\bpmc\d+`, String.raw`\bnct\d{8}\b`, String.raw`\bchictr-?\w+`,
46  String.raw`\bchr(?:\d{1,2}|x|y|mt?)\s*[:-]\s*[\d,]+(?:\s*[-_]\s*[\d,]+)?`,
47  String.raw`(?<![\w-])(?:\d{1,2}|x|y|mt?)\s*:\s*[\d,]+(?:\s*[-_]\s*[\d,]+)?`,
48  String.raw`\b[cgpnmr]\.\S+`,
49  String.raw`\b10\.\d{4,9}/\S+`,
50].join('|'), 'g')
51
52/** The folded text with separators kept (for word-boundary checks), removed, and digits only. */
53function forms(text: string): { spaced: string; tight: string; digits: string; scrubbed: string } {
54  const folded = fold(text)
55  // scientific identifiers removed: what a record number, a date or an ID number could hide in
56  const scrubbed = folded.replace(SCIENTIFIC_IDS, ' ').replace(/[\s_]+/g, ' ')
57  return {
58    spaced: folded.replace(/[\s_]+/g, ' '),
59    tight: folded.replace(SEPARATORS, ''),
60    digits: scrubbed.replace(/\D+/g, ''),
61    scrubbed,
62  }
63}
64
65const HAS_CJK = /[㐀-鿿豈-﫿]/
66
67const MAX_DEPTH = 12
68const MAX_LEAVES = 5000
69
70/**
71 * Every string in the input: values, and the keys of free-form objects (another server's
72 * `{ data: { "Zhang Wei": 1 } }` carries the name in a key). A key is checked as a whole value,
73 * never joined to its neighbours, so a schema key such as "variant" matches nothing.
74 * Past MAX_DEPTH or MAX_LEAVES the input is too large to read in full: `TOO_DEEP` is returned
75 * among the leaves so the caller refuses instead of passing what it did not read.
76 */
77export const TOO_DEEP = '\u0000zebra:unread'
78function leaves(value: unknown, depth = 0, out: string[] = []): string[] {
79  if (depth > MAX_DEPTH || out.length > MAX_LEAVES) {
80    if (out[out.length - 1] !== TOO_DEEP) out.push(TOO_DEEP)
81    return out
82  }
83  if (typeof value === 'string') out.push(value)
84  else if (Array.isArray(value)) for (const v of value) leaves(v, depth + 1, out)
85  else if (value && typeof value === 'object') {
86    for (const [k, v] of Object.entries(value)) {
87      if (!/^[a-z][a-z0-9_]*$/.test(k)) out.push(k) // a key that is not an identifier-shaped field name
88      leaves(v, depth + 1, out)
89    }
90  } else if (typeof value === 'number' || typeof value === 'boolean') out.push(String(value))
91  return out
92}
93
94const LONG_DIGITS = /\d{6,}/
95// written as one number, or in its 6-8-4 / 6-4-2-2-4 groups; never two separate numbers joined
96const CN_RESIDENT_ID = /(?<![\d])[1-9]\d{5}[ -]?(?:18|19|20)\d{2}[ -]?(?:0[1-9]|1[0-2])[ -]?(?:0[1-9]|[12]\d|3[01])[ -]?\d{3}[\dx](?![\d])/
97// Separators inside are allowed; a letter next to it is not, so rs13812345678 is a variant id
98const CN_MOBILE = /(?<![a-z0-9])(?:\+?0{0,2}86[\s-]?)?1[3-9]\d(?:[\s-]?\d){8}(?![\s-]?\d)(?![a-z0-9])/
99
100const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october',
101  'november', 'december']
102
103/** [year, month, day] readings of a registered date; d/m/y and m/d/y are both kept when ambiguous. */
104function dateParts(identifier: string): Array<[string, number, number]> {
105  const t = identifier.trim()
106  const ymd = /^(\d{4})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*日?$/.exec(t)
107  if (ymd) return [[ymd[1] as string, Number(ymd[2]), Number(ymd[3])]]
108  const xy = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/.exec(t)
109  if (!xy) return []
110  const a = Number(xy[1])
111  const b = Number(xy[2])
112  return [[xy[3] as string, b, a], [xy[3] as string, a, b]] // day-month-year, month-day-year
113}
114
115/**
116 * Patterns for a registered date in the ways people write one: 2019-03-02, 2019/3/2, 20190302,
117 * 2019年3月2日, 02/03/2019, 3/2/2019, March 2, 2019, 2 Mar 2019. Digit boundaries on both sides,
118 * so the same digits inside a coordinate or another date (12 March) never match.
119 */
120function dateMatchers(identifier: string): RegExp[] {
121  const out: RegExp[] = []
122  for (const [y, m, d] of dateParts(identifier)) {
123    if (m < 1 || m > 12 || d < 1 || d > 31) continue
124    const mo = `0?${m}`
125    const da = `0?${d}`
126    const sep = String.raw`\s*[-/.]\s*`
127    const month = MONTHS[m - 1] as string
128    const name = `(?:${month}|${month.slice(0, 3)}\\.?)`
129    const ord = '(?:st|nd|rd|th)?'
130    const yy = y.slice(2)
131    const loose = String.raw`[\s\-/.,]*`
132    out.push(
133      new RegExp(String.raw`(?<!\d)${y}\s*[-/.年]\s*${mo}\s*[-/.月]\s*${da}(?!\d)`),
134      new RegExp(String.raw`(?<!\d)${y}${String(m).padStart(2, '0')}${String(d).padStart(2, '0')}(?!\d)`),
135      new RegExp(String.raw`(?<!\d)${da}${sep}${mo}${sep}${y}(?!\d)`),
136      new RegExp(String.raw`(?<!\d)${mo}${sep}${da}${sep}${y}(?!\d)`),
137      new RegExp(String.raw`(?<!\d)${yy}[-/.]${mo}[-/.]${da}(?!\d)`), // 19-03-02
138      new RegExp(String.raw`(?<![a-z])${name}${loose}${da}${ord}${loose}${y}(?!\d)`), // March 2, 2019 / Mar-02-2019
139      new RegExp(String.raw`(?<!\d)${da}${ord}${loose}(?:of\s*)?${name}${loose}${y}(?!\d)`), // 2 March 2019 / 02-Mar-2019
140    )
141  }
142  return out
143}
144
145function escapeRe(text: string): string {
146  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
147}
148
149/**
150 * What in `input` must not leave the machine, described without repeating it.
151 * `identifiers` are the case's registered strings (names, dates of birth, record numbers).
152 */
153export function guardInput(input: unknown, identifiers: readonly string[]): string | undefined {
154  const all = leaves(input)
155  if (all.includes(TOO_DEEP)) return 'more nested data than the gate can read in full'
156  const values = all.filter(v => !isLocalPath(v))
157  if (values.length === 0) return undefined
158  // Each value is folded on its own: digits or letters from two unrelated values
159  // (an MRN and an HPO id next to it) must never join into a match.
160  const folded = values.map(forms)
161
162  for (let i = 0; i < identifiers.length; i++) {
163    const raw = (identifiers[i] ?? '').trim()
164    if (!raw) continue
165    const label = `protected identifier #${i + 1} from the case`
166    const id = forms(raw)
167    // A short number (a year, a floor, an age) identifies nobody and would block
168    // ordinary queries; a record number is longer. Dates are handled below.
169    if (/^\d+$/.test(id.tight) && id.tight.length < 6) continue
170    const dates = dateMatchers(raw)
171    const tokens = id.spaced.split(' ').filter(t => t.length >= 2)
172
173    for (const f of folded) {
174      // a date of birth, however it is written
175      if (dates.some(re => re.test(f.scrubbed))) return label
176      // a record or ID number: digits only, so separators cannot hide it
177      // (not for a date: its digits joined across a value's other numbers would match by accident)
178      if (dates.length === 0 && LONG_DIGITS.test(id.digits) && f.digits.includes(id.digits)) return label
179      // a number-only identifier is matched by its digits alone (a PMID or rsID with the same digits is not it)
180      if (/^\d+$/.test(id.tight)) continue
181      if (HAS_CJK.test(raw)) {
182        // Chinese names: two characters identify, and spacing carries no meaning
183        if (id.tight.length >= 2 && f.tight.includes(id.tight)) return label
184        continue
185      }
186      // every token present as a whole word, in any order ("Xiaoming Zhang" too)
187      if (tokens.length > 1 && tokens.every(t => wordIn(t, f.spaced))) return label
188      if (id.tight.length >= 3) {
189        if (wordIn(id.tight, f.spaced)) return label
190        if (tokens.length <= 1 && id.tight.length >= 6 && f.tight.includes(id.tight)) return label
191      }
192    }
193  }
194
195  // Patterns that identify a person even when no case is open, checked per value.
196  for (const f of folded) {
197    if (CN_RESIDENT_ID.test(f.scrubbed)) return 'what looks like a Chinese resident ID number'
198    if (CN_MOBILE.test(f.scrubbed)) return 'what looks like a mobile phone number'
199    if (emailIn(f.spaced)) return 'an email address'
200  }
201  return undefined
202}
203
204/** An email address in `text` that is not a host login (ssh user@host) or a git remote. */
205function emailIn(text: string): boolean {
206  // anchored at each '@' and bounded on both sides: a 200 kB sequence without one costs nothing
207  for (let at = text.indexOf('@'); at >= 0; at = text.indexOf('@', at + 1)) {
208    const left = /[a-z0-9._%+-]{1,64}$/.exec(text.slice(Math.max(0, at - 64), at))
209    const right = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,24}/.exec(text.slice(at + 1, at + 256))
210    if (!left || !right) continue
211    const addr = `${left[0]}@${right[0]}`
212    const m = { index: at - left[0].length }
213    if (/^git@/.test(addr) || /@(?:github|gitlab|bitbucket)\.(?:com|org)$/.test(addr)) continue
214    const before = text.slice(Math.max(0, m.index - 400), m.index)
215    if (/\b(?:ssh|scp|sftp|rsync|mosh|ssh-copy-id|autossh)\b[^;|&]*$/.test(before)) continue
216    return true
217  }
218  return false
219}
220
221function wordIn(word: string, text: string): boolean {
222  return new RegExp(`(?:^|[^a-z0-9])${escapeRe(word)}(?:[^a-z0-9]|$)`).test(text)
223}
224
225/** A bare local path (a case folder, a records file): its text never leaves the machine by itself. */
226export function isLocalPath(value: string): boolean {
227  const v = value.trim()
228  if (!v || /\s/.test(v) || v.includes('://') || /[?=&#]/.test(v)) return false
229  return /^(?:~|\$HOME|\.{1,2})?\//.test(v) || /^[A-Za-z]:\\/.test(v)
230}
231
232// ---------------------------------------------------------------- shell commands
233
234const NETWORK_TOOL =
235  /\b(?:curl|wget|nc|ncat|socat|telnet|scp|sftp|rsync|ssh|mosh|ftp|lftp|gh|glab|aws|gsutil|azcopy|rclone|mail|mailx|sendmail|mutt|osascript|dig|nslookup|host|s2f|httpie|http|xh|aria2c)\b|\bgit\s+(?:push|clone|fetch|pull|remote|ls-remote)\b|\b(?:npm|pnpm|yarn)\s+(?:publish|install|i|add)\b|\bnpx\b|\bpip3?\s+install\b|https?:\/\/|\/dev\/(?:tcp|udp)\/|\bopen\b[^|;&]*\b(?:mailto|https?):|\bdocker\s+(?:run|push|exec)\b|\b(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\b/i
236const SCRIPT_RUN = /\bpython3?(?:\.\d+)?\b|\bnode\b|\bdeno\b|\bbun\b|\bruby\b|\bperl\b|\bphp\b|\blua\b|\bjulia\b|\bjava\b|\bRscript\b|\bR\s+(?:-e|--vanilla|-f)\b|\buvx?\b|\bpipx\s+run\b|\b(?:pwsh|powershell)\b/
237const ZEBRA_CLI = /(?:^|[\s;&|(/])zebra(?![\w-])/
238// `zebra case …` reads and writes the local case only (its HPO checks send ids, never text),
239// so it stays usable while the gate is closed and its arguments are not outbound content.
240// Only when those are the leading words of the segment, as the shell will read them.
241function isZebraLocal(segment: string): boolean {
242  const w = shellWords(segment)
243  let i = 0
244  while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i] as string)) i++ // VAR=value prefixes
245  if (/(?:^|\/)python3?(?:\.\d+)?$/.test(w[i] ?? '')) i++
246  if (!/(?:^|\/)zebra$/.test(w[i] ?? '')) return false
247  i++
248  while (i < w.length) {
249    const a = w[i] as string
250    if (a === '--json') i++
251    else if (a === '--case') i += 2
252    else if (a.startsWith('--case=')) i++
253    else break
254  }
255  // `case <action>` reads and writes the case; `case recheck` asks public databases again.
256  // No readable action (`zebra case $(…)`) is not local. `report export` writes files here only.
257  const action = w[i + 1] ?? ''
258  if (w[i] === 'case') return /^[a-z][a-z-]*$/.test(action) && action !== 'recheck'
259  return w[i] === 'report' && action === 'export'
260}
261
262/** The words of a zebra invocation in this segment, from the subcommand on; null when it is not zebra. */
263function zebraWords(segment: string): string[] | null {
264  const w = shellWords(segment)
265  const i = w.findIndex(a => /(?:^|\/)zebra$/.test(a))
266  return i < 0 ? null : w.slice(i + 1)
267}
268// flags whose values stay on this machine in any zebra command (sample names, file paths)
269const ZEBRA_LOCAL_FLAGS = new Set(['--case', '--proband', '--mother', '--father', '--sibling', '--out', '--genes',
270  '--ped', '--csv', '--workspace', '--vcf'])
271
272/** Split a command into the pieces that run on their own, so one exempt piece cannot cover the rest. */
273export function shellSegments(command: string): string[] {
274  const out: string[] = []
275  let depth = 0
276  let current = ''
277  let quote: '"' | "'" | null = null
278  for (let i = 0; i < command.length; i++) {
279    const c = command[i] as string
280    const two = command.slice(i, i + 2)
281    // inside quotes, ; & | and newlines are text (curl "…?a=1&b=2"), not separators
282    if (quote) {
283      if (c === '\\' && quote === '"' && i + 1 < command.length) {
284        current += c + command[++i]
285        continue
286      }
287      if (c === quote) quote = null
288      current += c
289      continue
290    }
291    if (c === '"' || c === "'") {
292      quote = c
293      current += c
294      continue
295    }
296    if (c === '`') {
297      // a backtick substitution runs on its own
298      if (current.trim()) out.push(current)
299      current = ''
300      continue
301    }
302    if (two === '$(' || two === '<(' || two === '>(') {
303      depth++
304      i++
305      if (current.trim()) out.push(current)
306      current = ''
307      continue
308    }
309    if (c === ')' && depth > 0) {
310      depth--
311      if (current.trim()) out.push(current)
312      current = ''
313      continue
314    }
315    if (two === '&&' || two === '||') {
316      i++
317      if (current.trim()) out.push(current)
318      current = ''
319      continue
320    }
321    if (c === ';' || c === '|' || c === '&' || c === '\n') {
322      if (current.trim()) out.push(current)
323      current = ''
324      continue
325    }
326    current += c
327  }
328  if (current.trim()) out.push(current)
329  return out
330}
331
332/**
333 * A segment as the shell will read it: quotes removed and words rejoined, so `c'u'rl` and
334 * `"cu""rl"` read as curl, and the text of `bash -c "…"` or `eval "…"` is seen as the command it is.
335 */
336function asRun(segment: string): string {
337  return shellWords(segment).join(' ')
338}
339
340function outboundSegment(segment: string): boolean {
341  if (isZebraLocal(segment)) return false // the local case only
342  for (const text of [segment, asRun(segment)]) {
343    if (NETWORK_TOOL.test(text)) return true
344    if (ZEBRA_CLI.test(text)) return true // zebra's other commands query public databases
345    if (SCRIPT_RUN.test(text)) return true // a script can reach the network; its source is not read here
346  }
347  // `bash -c "…"`, `sh -c`, `eval`: judge the inner command the same way
348  const w = shellWords(segment)
349  const inner = /^(?:ba|z|da|k)?sh$|^eval$/.test(w[0] ?? '') ? w.slice(1).filter(a => a !== '-c').join(' ') : ''
350  return inner !== '' && inner !== segment && isOutboundShell(inner)
351}
352
353/** True when a shell command may send data off the machine. Judged per segment. */
354export function isOutboundShell(command: string): boolean {
355  return shellSegments(command).some(outboundSegment)
356}
357
358/**
359 * The text of a shell command that may reach the network. Once any part of it does, every part is
360 * scanned — a pipe (echo … | curl), a heredoc, a variable set before the call or a script's source
361 * all carry text to the outbound piece — except what demonstrably stays here: segments that only
362 * touch the local case, the values of zebra's local-only flags (sample names, case paths) inside
363 * zebra commands, local paths, and the folder given to `cd`.
364 */
365export function outboundText(command: string): string {
366  const segments = shellSegments(command)
367  if (!segments.some(outboundSegment)) return ''
368  const kept: string[] = []
369  for (const segment of segments) {
370    if (isZebraLocal(segment)) continue
371    const words = shellWords(segment)
372    const zebra = zebraWords(segment) !== null
373    if (words[0] === 'cd') continue
374    for (let i = 0; i < words.length; i++) {
375      const w = words[i] as string
376      if (zebra && ZEBRA_LOCAL_FLAGS.has(w)) {
377        i++
378        continue
379      }
380      if (zebra && /^--[\w-]+=/.test(w) && ZEBRA_LOCAL_FLAGS.has(w.split('=')[0] as string)) continue
381      if (isLocalPath(w)) continue
382      kept.push(w)
383    }
384  }
385  return kept.join(' ')
386}
387
388const GENOME_FILE = /\.(?:g?vcf(?:\.gz|\.bgz)?|bcf|bam|cram|sam|fastq(?:\.gz)?|fq(?:\.gz)?|bed(?:\.gz)?)(?![a-z0-9])/i
389const UPLOADER =
390  /\b(?:scp|sftp|rclone|gsutil|azcopy)\b|\baws\s+s3\b|\bgh\s+(?:gist|release)\s+\w+|\brsync\b[^|;&]*\S+:\S*|\bcurl\b[^|;&]*(?:\s-T\b|--upload-file|\s-F\b|--form|--data-binary|\s-d\s*@|--data(?:-raw|-urlencode)?\s*@)|\bwget\b[^|;&]*--post-file|\b(?:nc|ncat|socat)\b|\bssh\b[^|;&]*\bcat\s*>|\bmail(?:x)?\b|\bpython3?\s+-m\s+http\.server\b/i
391
392// folders a desktop client uploads on its own: copying a file there sends it
393const SYNC_FOLDER = /(?:^|[\s'"=])(?:~|\$HOME|\/Users\/[^/\s]+|\/home\/[^/\s]+)?\/?(?:Dropbox|Google Drive|GoogleDrive|My Drive|OneDrive[^/\s]*|Library\/Mobile Documents|Library\/CloudStorage|iCloud Drive|Nutstore[^/\s]*|坚果云[^/\s]*|BaiduNetdisk[^/\s]*|百度网盘[^/\s]*|WeDrive[^/\s]*|Box Sync|pCloud Drive|MEGA)(?:\/|['"\s]|$)/i
394const COPY_INTO = /\b(?:cp|mv|rsync|ditto|ln|install|tee)\b/
395
396/** True when a zebra command would send a whole VCF's variant list to a web service (triage's MyVariant prefilter). */
397export function sendsVariantList(command: string): boolean {
398  // argparse accepts any unique prefix of a long option: --pref myvariant, --pre=myvariant
399  const isFlag = (a: string) => a.length >= 5 && '--prefilter'.startsWith(a)
400  return shellSegments(command).some(seg => {
401    const rest = zebraWords(seg)
402    if (!rest) return false
403    return rest.some((a, i) => {
404      const [flag, value] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, rest[i + 1]]
405      return isFlag(flag) && value === 'myvariant'
406    })
407  })
408}
409
410/** True when a command looks like it sends a raw genome file to another machine. */
411export function uploadsGenome(command: string): boolean {
412  if (GENOME_FILE.test(command) && UPLOADER.test(command)) return true
413  // copying genome data into a synced folder uploads it as surely as scp
414  if (GENOME_FILE.test(command) && COPY_INTO.test(command) && SYNC_FOLDER.test(command)) return true
415  // an archive that holds genome data, then sent: zip -r g.zip genome/ && curl -F f=@g.zip …
416  const archive = /\b(?:zip|7z|7za|tar|gzip|bgzip|xz|zstd)\b[^|;&]*?([\w.-]+\.(?:zip|7z|tar(?:\.gz|\.bz2|\.xz|\.zst)?|tgz|gz))\b/i.exec(command)
417  if (archive && (GENOME_FILE.test(command) || /\b(?:genome|exome|wes|wgs|vcf|bam|cram|fastq)s?\b/i.test(command)) && UPLOADER.test(command)) return true
418  const segments = shellSegments(command)
419  if (segments.some(s => GENOME_FILE.test(s)) && segments.some(s => UPLOADER.test(s))) return true
420  // a whole directory going out (scp -r genome_dir host:, tar czf - dir | ssh)
421  return /\bscp\s+-r\b|\btar\b[^|;&]*\bc/.test(command) && /\b(?:ssh|scp|rclone)\b|\baws\s+s3\b/.test(command)
422}
423
424const FILE_ARGS: RegExp[] = [
425  /--(?:upload-file|post-file)[=\s]+("[^"]+"|'[^']+'|\S+)/gi,
426  /\s-T\s*("[^"]+"|'[^']+'|\S+)/gi,
427  /(?:--data-binary|--data-urlencode|--data-raw|--data|-d|-F|--form)\s*(?:[^@\s]*@)("[^"]+"|'[^']+'|[^\s;|&]+)/gi,
428  /<\s*("[^"]+"|'[^']+'|[^\s;|&]+)/g,
429]
430// commands whose non-option, non-remote arguments are local files being sent
431const COPY_TOOLS = /^(?:scp|sftp|rsync|rclone|gsutil|azcopy)$/
432const REMOTE_ARG = /^(?:[\w.-]+@)?[\w.-]+:(?!\/\/)|^(?:s3|gs|az|https?):\/\/|^[\w-]+:$/
433
434/**
435 * The local file paths a command would send somewhere, relative paths resolved against a
436 * preceding `cd` in the same command, so the caller can check whether they lie in the case folder.
437 */
438export function uploadedPaths(command: string): string[] {
439  const found = new Set<string>()
440  // a file copied into a synced folder is uploaded by the desktop client
441  for (const segment of shellSegments(command)) {
442    const w = shellWords(segment)
443    if (COPY_INTO.test(w[0] ?? '') && w.length >= 3 && SYNC_FOLDER.test(` ${w[w.length - 1]}`)) {
444      for (const a of w.slice(1, -1)) if (!a.startsWith('-')) found.add(a)
445    }
446  }
447  let cwd = ''
448  const segments = shellSegments(command)
449  const join = (p: string) => (cwd && !/^(?:\/|~|\$HOME)/.test(p) ? `${cwd.replace(/\/$/, '')}/${p}` : p)
450  // files read on the left of a pipe that ends in an uploader reading stdin
451  const pipeReaders: string[] = []
452  const archived: string[] = []
453  const scripted = SCRIPT_RUN.test(command)
454  for (const segment of segments) {
455    const words = shellWords(segment)
456    const head = words[0] ?? ''
457    if (head === 'cd' && words[1]) {
458      cwd = words[1]
459      continue
460    }
461    if (/^(?:cat|zcat|gzip|bgzip|tar|base64|xxd|head|tail)$/.test(head)) {
462      for (const w of words.slice(1)) if (!w.startsWith('-')) pipeReaders.push(join(w))
463    }
464    for (const re of FILE_ARGS) {
465      re.lastIndex = 0
466      let m: RegExpExecArray | null
467      while ((m = re.exec(segment)) !== null) {
468        const capture = m[1] ?? ''
469        const parts = /^['"]/.test(capture) ? [capture] : capture.split(/\s+/)
470        for (const part of parts) {
471          const clean = part.replace(/^['"]|['"]$/g, '')
472          if (clean === '-') pipeReaders.forEach(r => found.add(r))
473          else if (clean && !/^https?:/.test(clean)) found.add(join(clean))
474        }
475      }
476    }
477    if (/^(?:zip|7z|7za|tar)$/.test(head)) {
478      // what goes into an archive leaves with it: zip -r out.zip ~/cases/x, tar czf out.tgz -C ~/cases x
479      let base = ''
480      for (let k = 1; k < words.length; k++) {
481        const a = words[k] as string
482        if (a === '-C' && words[k + 1]) {
483          base = words[++k] as string
484          continue
485        }
486        if (a.startsWith('-') || /\.(?:zip|7z|tar|tgz|gz|bz2|xz|zst)$/.test(a)) continue
487        archived.push(base && !/^(?:\/|~|\$HOME)/.test(a) ? `${base.replace(/\/$/, '')}/${a}` : join(a))
488      }
489    }
490    if (scripted && zebraWords(segment) === null) {
491      // a script that opens a file from the case folder sends its contents: python -c "…open('…')…",
492      // or a heredoc body; only paths that start a word (not the tail of records/x or host:/tmp)
493      for (const m of segment.replace(/https?:\/\/\S+/g, ' ').matchAll(/(?<=^|[\s'"=(,])(?:~|\$HOME)?\/[^\s'"<>|;&(),]{2,}/g)) found.add(m[0])
494    }
495    if (head === 'gh' && words[1] === 'release' && words[2] === 'upload') {
496      for (const w of words.slice(4)) if (!w.startsWith('-')) found.add(join(w))
497    }
498    if (COPY_TOOLS.test(head) || (head === 'aws' && words[1] === 's3') || (head === 'gh' && words[1] === 'gist')) {
499      const args = words.slice(head === 'aws' || head === 'gh' ? 3 : 1)
500      for (const w of args) {
501        if (w.startsWith('-') || REMOTE_ARG.test(w)) continue
502        found.add(join(w))
503      }
504    }
505  }
506  // the archive's sources count once anything in the command sends something somewhere
507  if (archived.length && (UPLOADER.test(command) || segments.some(outboundSegment))) archived.forEach(a => found.add(a))
508  return [...found]
509}
510
511/** A tiny shell-words splitter, so `/zebra new "my case" A title` works. */
512export function shellWords(text: string): string[] {
513  const out: string[] = []
514  let current = ''
515  let quote: '"' | "'" | null = null
516  let started = false
517  for (let i = 0; i < text.length; i++) {
518    const c = text[i] as string
519    if (quote) {
520      if (c === quote) quote = null
521      else if (c === '\\' && quote === '"' && i + 1 < text.length) current += text[++i]
522      else current += c
523      continue
524    }
525    if (c === '"' || c === "'") {
526      quote = c as '"' | "'"
527      started = true
528      continue
529    }
530    if (c === '\\' && i + 1 < text.length) {
531      current += text[++i]
532      started = true
533      continue
534    }
535    if (/\s/.test(c)) {
536      if (current || started) out.push(current)
537      current = ''
538      started = false
539      continue
540    }
541    current += c
542    started = true
543  }
544  if (current || started) out.push(current)
545  return out
546}
547
hooks/ui-sprite.ts 163 lines
1// The galloping zebra drawn in the band above the prompt while zebra-mod queries a database.
2// Eleven positions of one gallop stride, traced from Eadweard Muybridge's "The Horse in Motion" (1878,
3// public domain): rider removed, frames aligned on the back (croup to withers) so the body stays
4// level while the legs move, given zebra stripes, drawn in braille (2 x 4 dots to a terminal cell). Regenerate with
5// `python3 tools/zebra_sprite/build.py`. One colour, the terminal's own text colour, so the zebra reads
6// on light and dark themes alike; the ground is a dim dotted line that runs backwards.
7// Pure functions of (frame, scroll): no state here.
8
9const FRAMES: readonly (readonly string[])[] = [
10  [
11    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢠⡴⣟⣶⣤⡀⠀',
12    '⠀⠀⠀⠀⢀⣀⢠⣤⡖⣤⢠⣤⡆⣶⣾⡟⣵⡏⠉⠉⠁⠀',
13    '⠀⠈⠷⠾⠛⠁⢻⡟⣼⣿⢸⣿⡇⣿⢏⣾⠟⠀⠀⠀⠀⠀',
14    '⠀⠀⠀⠀⠀⡟⢟⠘⠋⠈⠘⠛⠃⢻⡿⠟⠀⠀⠀⠀⠀⠀',
15    '⠀⠀⠀⠀⠴⠃⠀⠱⠀⠀⠒⠦⠤⠟⠀⠀⠀⠀⠀⠀⠀⠀',
16    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
17  ],
18  [
19    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣾⣳⣦⣀⠀⠀',
20    '⠀⠀⠀⠀⠀⠀⢀⣤⣶⣤⢠⣤⡄⣦⣿⡟⣽⠉⠉⠙⠀⠀',
21    '⠀⢴⣤⡶⠟⠁⢿⡟⣼⣿⢸⣿⡇⣿⢏⣾⠃⠀⠀⠀⠀⠀',
22    '⠀⠀⠉⠀⠀⠀⢀⣼⣿⠏⠘⠛⠃⣧⢿⡋⠀⠀⠀⠀⠀⠀',
23    '⠀⠀⠀⠀⠀⠀⠀⠈⢻⢓⣢⣀⡼⠳⠔⠛⠀⠀⠀⠀⠀⠀',
24    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
25  ],
26  [
27    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⡿⢶⣤⡀⠀⠀',
28    '⠀⠀⠀⠀⠀⠀⢀⣴⣶⣦⢠⣤⡄⣦⣿⡟⡵⠉⠉⠋⠀⠀',
29    '⠀⢠⣤⠾⠛⠃⢿⡟⣼⣿⢸⣿⡇⣿⢏⣾⠃⠀⠀⠀⠀⠀',
30    '⠀⠀⠁⠀⠀⠀⠀⢨⣿⣿⠘⠛⠇⣧⠿⠟⣄⠀⠀⠀⠀⠀',
31    '⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠛⠦⡤⠏⠰⠖⠁⠀⠀⠀⠀⠀',
32    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
33  ],
34  [
35    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡷⡶⣤⡀⠀⠀',
36    '⠀⠀⠀⠀⠀⠀⢀⣤⣤⣤⢠⣤⡄⣦⣿⡟⣵⠛⠛⠿⠂⠀',
37    '⠀⢀⣴⠿⠻⠃⢿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡇⠀⠀⠀⠀⠀',
38    '⠀⠙⠁⠀⠀⠀⠈⠘⢿⣿⠸⠿⠇⠧⣿⣟⠔⢲⠀⠀⠀⠀',
39    '⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⡓⠒⠦⠜⠶⠚⠁⠸⠀⠀⠀⠀',
40    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢙⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
41  ],
42  [
43    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣄⡶⡶⣦⡀⠀⠀',
44    '⠀⠀⠀⢀⣀⣀⢀⣤⣤⣤⢠⣤⡄⣦⣿⡟⣵⠋⠛⠻⠂⠀',
45    '⠀⢠⣾⠟⠛⠁⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡇⠀⠀⠀⠀⠀',
46    '⠀⠋⠁⠀⠀⠀⠈⣼⠟⢿⡘⠛⠃⠧⠟⠛⢼⠿⢄⡀⠀⠀',
47    '⠀⠀⠀⠀⠀⠀⠰⡏⠀⠈⠙⠢⢤⡀⠀⠘⠋⠀⠀⠙⠀⠀',
48    '⠀⠀⠀⠀⠀⠀⠀⠳⠤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
49  ],
50  [
51    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡦⣣⡷⣄⠀⠀',
52    '⠀⠀⠀⣀⣀⣀⢀⣤⡄⣤⢠⣤⡄⣶⣾⡟⣵⠟⠙⠻⠷⠀',
53    '⠀⣴⠟⠋⠉⢡⣿⡟⣼⣿⢸⣿⡇⣿⣏⣾⡏⠀⠀⠀⠀⠀',
54    '⠀⠁⠀⠀⢀⣠⠟⠘⣿⠉⠘⠛⠃⠛⠛⠛⠸⢿⡇⠀⠀⠀',
55    '⠀⠀⠀⢀⡞⠁⠀⠀⠻⡀⠀⠀⠀⠀⠀⠀⠀⠐⠋⠓⠦⠄',
56    '⠀⠀⠀⠸⠷⠀⠀⠀⠀⠹⠶⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
57  ],
58  [
59    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⡰⣠⡶⣄⠀⠀',
60    '⠀⠀⠀⠀⣀⣀⢠⣤⣤⣤⢀⣤⡄⣶⣾⡟⣵⠟⠙⠻⠷⠀',
61    '⠤⠾⠛⠋⠉⢠⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡏⠀⠀⠀⠀⠀',
62    '⠀⠀⠀⣠⡠⣾⡟⠈⠉⠉⠘⠛⠃⠛⠛⠛⣜⠛⠒⢤⡀⠀',
63    '⠠⠤⠞⠁⢰⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢧⡀⠀⠙⠂',
64    '⠀⠀⠀⠀⠘⠓⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⠂⠀⠀',
65  ],
66  [
67    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡴⣣⣶⣄⠀⠀',
68    '⠀⠀⠀⠀⣀⡤⢠⣤⣤⣤⢠⣤⡄⣶⣾⡟⣵⡿⠛⠺⠷⠀',
69    '⠛⠻⠟⠛⠉⢠⣿⡟⣼⣿⢸⣿⡇⣿⣏⣾⡿⠀⠀⠀⠀⠀',
70    '⠀⢀⣠⠴⣢⡿⠛⠈⠀⠉⠘⠛⠃⠛⠻⡟⠘⠷⣤⡀⠀⠀',
71    '⠉⠉⢀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⠀⠀⠀⠈⠉⠋',
72    '⠀⠀⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠂⠀⠀⠀⠀⠀',
73  ],
74  [
75    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢠⡰⣲⢶⣄⠀⠀',
76    '⢀⢀⠀⢀⣠⣤⢠⣤⣤⣤⢠⣤⡄⣶⣾⡟⣵⡿⠛⠻⠷⠀',
77    '⠈⠛⠛⠛⠉⢠⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡟⠁⠀⠀⠀⠀',
78    '⠀⠀⢀⣼⡧⠟⠛⠈⠁⠉⠘⠛⠇⣿⠿⠛⢴⡄⠀⠀⠀⠀',
79    '⠀⠛⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⡰⠋⠀⠀⠀⠙⢢⡀⠀⠀',
80    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠃⠀⠀⠀⠀⠀⠀⠉⠋⠀',
81  ],
82  [
83    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢀⡠⣞⣶⣄⠀⠀',
84    '⠀⢀⡀⢀⣀⣤⢠⣴⡖⣤⢠⣤⡆⣶⣾⡟⣵⡟⠙⠛⠃⠀',
85    '⠀⠀⠻⠟⠋⠁⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⠏⠀⠀⠀⠀⠀',
86    '⠀⠀⠀⢀⠞⢻⠛⠘⠉⠈⠘⠛⠃⣿⣿⠋⠀⠀⠀⠀⠀⠀',
87    '⠀⠀⠈⠉⠀⠰⠁⠀⠀⠀⠀⠀⠼⠁⠿⠀⠀⠀⠀⠀⠀⠀',
88    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
89  ],
90  [
91    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢠⡴⣞⣶⣄⠀⠀',
92    '⠀⠀⡀⣀⣀⣀⢀⣤⡄⣤⢠⣤⡄⣶⣾⡟⣵⡟⠙⠛⠃⠀',
93    '⠀⠘⠿⠿⠟⠃⢹⡟⣼⣿⢸⣿⡇⣿⢏⣾⡟⠀⠀⠀⠀⠀',
94    '⠀⠀⠀⠀⠀⠐⢞⣼⠟⠉⠘⠛⠃⣿⡿⠟⠀⠀⠀⠀⠀⠀',
95    '⠀⠀⠀⠀⠀⠀⠈⡏⠙⢢⠀⣀⠿⠿⠇⠀⠀⠀⠀⠀⠀⠀',
96    '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
97  ],
98]
99
100const DEFAULT = 0x01000000 // the terminal's own colour
101const GROUND = 0x6e6c64
102const SPRITE_COLS = 22
103const STANDING = 5 // the pose shown still (the welcome card, the shield)
104
105/** Columns and rows of the zebra's Raster: the sprite with a little ground before and behind it. */
106export const RASTER_COLUMNS = 28
107export const RASTER_ROWS = 6
108export const FRAME_COUNT = FRAMES.length
109const OFFSET = 3
110
111/** The braille dot bits of the cell at (col, row): the zebra's, then the ground's in the lowest dot row. */
112function cell(frame: number, scroll: number, col: number, row: number, isStanding: boolean): { horse: number; ground: number } {
113  const rows = FRAMES[isStanding ? STANDING : frame % FRAMES.length] as readonly string[]
114  const sc = col - OFFSET
115  const ch = sc >= 0 && sc < SPRITE_COLS ? (rows[row] as string).codePointAt(sc) ?? 0x2800 : 0x2800
116  const horse = ch - 0x2800
117  let ground = 0
118  if (row === RASTER_ROWS - 1) {
119    // dot 7 (left column, bottom) and dot 8 (right column, bottom), one in every six dots
120    const s = isStanding ? 0 : scroll
121    if ((col * 2 + s) % 6 === 0) ground |= 0x40
122    if ((col * 2 + 1 + s) % 6 === 0) ground |= 0x80
123  }
124  return { horse, ground }
125}
126
127/** The Raster `cells` of one frame: braille dots in the terminal's colour, the ground dimmed. */
128export function zebraCells(frame: number, scroll: number, isStanding = false): string {
129  const words = new Uint32Array(RASTER_COLUMNS * RASTER_ROWS * 3)
130  let i = 0
131  for (let row = 0; row < RASTER_ROWS; row++) {
132    for (let col = 0; col < RASTER_COLUMNS; col++) {
133      const { horse, ground } = cell(frame, scroll, col, row, isStanding)
134      const bits = horse | ground
135      words[i++] = bits ? 0x2800 + bits : 0x20
136      words[i++] = horse ? DEFAULT : ground ? GROUND : DEFAULT
137      words[i++] = DEFAULT
138    }
139  }
140  return base64(new Uint8Array(words.buffer))
141}
142
143const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
144
145/** Standard padded base64 (the module runs with no Node and no DOM, so no Buffer or btoa to lean on). */
146export function base64(bytes: Uint8Array): string {
147  let out = ''
148  let i = 0
149  for (; i + 2 < bytes.length; i += 3) {
150    const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number)
151    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
152  }
153  const rest = bytes.length - i
154  if (rest === 1) {
155    const n = (bytes[i] as number) << 16
156    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
157  } else if (rest === 2) {
158    const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8)
159    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
160  }
161  return out
162}
163
types/index.d.ts 72 lines
1export type BoardPhenotype = { id: string; label: string | null; status: string }
2
3export type BoardVariant = {
4  id: string
5  gene: string | null
6  label: string | null
7  zygosity: string | null
8  classification: string | null
9}
10
11export type BoardHypothesis = { id: string; disease: string; status: string; support: number; against: number }
12
13export type BoardLead = { id: string; name: string; kind: string; status: string | null }
14
15export type BoardTest = { id: string; type: string; date: string | null; result: string | null }
16
17export type BoardRelative = { id: string; relation: string | null; affected: boolean | string | null; genotype: string | null }
18
19export type Board = {
20  path: string
21  id: string
22  title: string
23  role: string
24  language: string | null
25  updated_at: string
26  phenotypes: BoardPhenotype[]
27  variants: BoardVariant[]
28  hypotheses: BoardHypothesis[]
29  therapy_leads: BoardLead[]
30  questions: string[]
31  tests?: BoardTest[]
32  family?: BoardRelative[]
33  evidence_count: number
34  identifiers: number
35}
36
37/** A zebra tool call in flight, for the spinner, the band and the row. */
38export type ZebraRun = { id: string; tool: string; queryZh: string; queryEn: string; sources: string[]; startedAt: number }
39
40/** What zebra-mod did in the current turn: the line left at its end. */
41export type ZebraTurn = {
42  calls: number
43  failed: number
44  blocked: number
45  sources: string[]
46  cached: number
47  evidence: number
48  firstEvidence: number | null
49  lastEvidence: number | null
50}
51
52export type Ready = { python: string | null; version: string | null; error: string | null }
53
54declare module 'claude-code' {
55  interface PluginState {
56    'zebra-mod': {
57      board: Board | null
58      casePath: string | null
59      guard: string[]
60      ready: Ready | null
61      trusted: string[]
62      sessionIds: string[]
63      running: ZebraRun[]
64      turn: ZebraTurn
65      blockedAt: number | null
66      lang: string | null
67      blockedWhy: string
68      onboarding: boolean
69    }
70  }
71}
72