What it is — A passive, read-only tracker that shows how you actually use OpenCode: every tool call, skill, subagent, MCP tool, model step and session.
What it solves — OpenCode persists sessions for resuming, not for answering "which skills do I actually use?"; ocut indexes that data into its own database and turns it into panels and reports.
How to run — ./install.sh, then ocut sync.
Where to look — See AGENTS.md and MAINTENANCE.md.
ocut shows how you actually use OpenCode: every tool call,
every skill and subagent, every MCP tool, every model step and its tokens, and every
session — what ran, when, whether it finished, how long it took, and in which project.
It is a passive reader. OpenCode keeps its own sessions in a single SQLite file
(~/.local/share/opencode/opencode.db); ocut indexes that file into its own database
(~/.local/share/opencode-usage-tracker/usage.db) and never writes back to OpenCode's
directories. One optional plugin hook fills the one gap the host cannot expose — which
plugin a tool call belonged to. Everything stays local: no network, no upload, no
message text.
OpenCode already persists what you need — but in a shape built for resuming sessions, not for answering "which of my skills do I actually use?".
- Every tool call is a
toolpart inopencode.db, whatever the tool is: a builtin, theskilltool, thetasktool that spawns a subagent, or an MCP server's tool. - Each model step is a
step-finishpart carrying that step's token counts and cost; the model lives on its parent message, not on the step. - Sessions, their projects and their directories are rows in
session. - What is installed — skills, agents, slash commands, MCP servers, plugins — lives in
the config home (
~/.config/opencode), not in the database.
ocut reads all of that read-only and turns it into panels and reports. Two things
it deliberately does not do:
- It never writes OpenCode's database or config. The host database is opened
mode=roandPRAGMA query_only = ON, so a bug in this project cannot make that claim false. - It never stores message or tool prose. A tool part carries
output,title,metadataanderror; atext/reasoningpart carries your prompt and the model's answer; a session row carries atitleOpenCode derives from your first message. None of those is read. Only identifiers, statuses, durations, token counts and counts land inusage.db.
Design constraints, kept on purpose:
- No OpenCode source changes — a passive reader plus one public plugin hook.
- No instrumentation in
SKILL.md— nothing to add to your skills. - One database of the tracker's own + one entry point + one optional hook, minimal deps.
OpenCode records a plugin's tool under the tool's own name, with nothing naming the
plugin, and records nothing at all for a plugin command. So ocut alone can never
attribute a plugin call. The optional hook plugin/ocut-hook.js closes that gap: at
load it statically scans each installed plugin's source for the tool and command ids it
registers, and on a call it can attribute it appends one record to a local JSONL sidecar
that ocut sync ingests. The hook is the only writer of plugin_usage, and it never
opens OpenCode's database.
| OS | Linux — developed and verified on Ubuntu; other distributions are unverified |
| OpenCode | 1.18.x — the host whose database and config this project reads (see compatibility) |
| Python | 3.11+ for the CLI and TUI; CI runs the suite on 3.11 and 3.14 |
| Textual | textual>=8.2,<9 — needed by the interactive TUI only, installed into a venv by install.sh |
uv |
optional but recommended for creating the venv; plain python3 -m venv also works |
Every headless subcommand (sync, stats, tools, health, export, backup,
restore, format, …) needs only the Python standard library. The hook runs inside
OpenCode's own Bun runtime and needs nothing installed separately.
git clone https://github.com/SHADE-glitch/opencode-usage-tracker.git
cd opencode-usage-tracker
./install.shThen restart OpenCode so it loads the hook, and verify:
ocut health # is the tracker database readable, and is the host database found?
ocut sync # index what OpenCode has written so far
ocut # launch the interactive TUI (eight tabs)install.sh is idempotent. It:
-
creates
./.venvand installsrequirements.txt(textual) into it; -
symlinks exactly two install locations back at this repo, so
git pullhere updates the live install:Install location Points at ~/.config/opencode/plugin/ocut-hook.jsplugin/ocut-hook.js~/.local/bin/ocutbin/ocutbin/ocutresolves its ownscripts/directory from the repo it lives in, so the scripts are never copied to a user path. If a location already holds a real file or directory, the script refuses to overwrite it and tells you what to move; pass--forceto replace it. -
runs
ocut healthonce and prints the next steps.
Make sure
~/.local/binis on yourPATH. If it is not, addexport PATH="$HOME/.local/bin:$PATH"to your shell profile.
The same thing, spelled out — useful if you prefer to place the repo elsewhere:
git clone https://github.com/SHADE-glitch/opencode-usage-tracker.git
cd opencode-usage-tracker
# 1. venv for the TUI
uv venv --python 3.13 .venv
uv pip install --python .venv/bin/python -r requirements.txt
# 2. point the two install locations at this repo
mkdir -p ~/.config/opencode/plugin ~/.local/bin
ln -sf "$PWD/plugin/ocut-hook.js" ~/.config/opencode/plugin/ocut-hook.js
ln -sf "$PWD/bin/ocut" ~/.local/bin/ocutNo Textual, no venv — just the reader and the reports:
mkdir -p ~/.local/bin
ln -sf "$PWD/bin/ocut" ~/.local/bin/ocutocut then falls back to the system python3 for every subcommand; only launching the
bare TUI needs the venv. You can also point the launcher at a venv of your own with
OCUT_VENV.
ocutNeeds a real terminal (stdin/stdout/stderr all TTYs).
Pages: Dashboard / Tools / Skills / Agents / Plugins / MCP / Tokens / Usage (eight
tabs, in that order). The tab bar and footer are the whole key surface:
| Key | Action |
|---|---|
q |
quit |
r |
refresh — run an incremental sync, then reload the screen |
s |
toggle auto-sync (the 30-second background indexer) |
tab |
next tab |
shift+tab |
previous tab |
enter / double-click |
open the selected row's detail screen |
escape / q |
go back from a pushed detail screen |
The Dashboard is three rows of panels. The first row is the headline counts and the token totals, each label printed beside its number:
| Panel | Labels |
|---|---|
| Usage | Tool calls, Skills, Agents, MCP, Plugins |
| Tokens | Requests, Input, Output, API Total, Usage Total, Hit rate |
| Cache | Cache hit, Cache miss, Cache write |
| Runtime | Completed, Incomplete, Avg tool time, Sessions, Projects |
Below those sit the Top models and Top tools leaderboards, and a Calls per day
trend. Plugins counts the distinct plugins the optional hook recorded in the window —
a passive reader has no evidence a plugin or command ran unless the hook wrote it (M3).
The Tools / Skills / Agents / Plugins / MCP tabs are one row per entity, with calls,
success, average duration and last use; enter on a row opens that entity's recent-call
history. The Tokens tab is per-model token totals from the host's own step records,
and the Usage tab is the request log for the window you pick. Each tab keeps its own
time window (a preset like 7d, or a custom span), so changing one tab's window never
moves another's.
A plugin call that the hook recorded carries a NULL status — a tool's after-hook carries
no status and a command has no after-hook at all — so a plugin row's status column reads
-, not 0.
ocut sync [--full] [--quiet] [--db PATH] [--host-db PATH] [--host-config PATH] [--plugin-events PATH]
ocut stats [--kind K] [--db PATH]
ocut tools [--limit N]
ocut skills [--limit N]
ocut agents [--limit N]
ocut mcp [--limit N]
ocut plugins
ocut models [--limit N]
ocut show <tool|skill|agent|mcp> <name> [--limit N]
ocut recent [--limit N]
ocut inventory [--kind K]
ocut export
ocut health
ocut backup
ocut restore [NAME]
ocut vacuum
ocut format [--write]sync— index OpenCode's database into the tracker's own.--fullrebuilds from scratch (taking a snapshot first);--quietis what the TUI's background worker runs. It is incremental: a rowid cursor consumes newpartrows, andsessionis re-read in full each run because the host rewrites its rollup.stats— an overview of every category: sessions, projects, tool calls, skill / agent / MCP invocations, plugins and commands installed, model steps and tokens.tools/skills/agents/mcp— one category's table.pluginsis the installed ∪ used union, with the owning plugin's version and declared path.models— per-model token totals from the host's ownstep-finishrecords, plus the most recent steps.Totalis the host's own step total when it sent one; cache read/write are shown separately and are not inside it.show <kind> <name>— the recent calls for one entity.recent— the most recent tool calls of any kind.inventory— what is installed or available.export— dump every view and table as JSON.health— a read-only report: the database path, the host database and config, the resolved settings, the schema version, each sync cursor against the host's own counts, the indexed totals, and — the point of the command — theunparsedcounters. A run that quietly claims nothing still prints "done";healthis where a format change becomes visible.backup/restore— snapshot the database, or list and restore a snapshot (see Backups).format— print the OpenCode format-dependency surface, read out of the parser's registry.--writerefreshes a generated block already present in a file, and refuses to create one: the markers are the opt-in, so the flag cannot bring a document into existence that nobody asked for.
Global value flags (--db, --kind, --limit, --host-db, --host-config,
--plugin-events) may sit before or after the subcommand: ocut --limit 5 tools and
ocut tools --limit 5 are the same. A command is never mistaken for a flag's value.
Zero configuration is a supported configuration: with no file, every key takes its
default and nothing is created. Settings live in ~/.config/ocut/config.toml
(override the path with OCUT_CONFIG; XDG_CONFIG_HOME is honoured), read with the
standard library's tomllib.
Precedence: environment → file → default.
| Key | Default | Environment | What it changes |
|---|---|---|---|
top_n |
5 |
OCUT_TOP_N |
rows in the Dashboard's Top models / Top tools |
log_limit |
100 |
OCUT_LOG_LIMIT |
request rows on the Usage tab |
detail_limit |
200 |
OCUT_DETAIL_LIMIT |
rows on a history / model-response screen |
name_cap |
28 |
OCUT_NAME_CAP |
widest cell a table renders before the name is clipped |
refresh_secs |
5 |
OCUT_REFRESH_SECS |
TUI redraw timer |
sync_secs |
30 |
OCUT_SYNC_SECS |
TUI auto-sync timer |
A file that cannot be read is an error, not a fallback: an unknown key, malformed
TOML, a value of the wrong type, or a value outside its bounds is a hard failure that
names the path (or variable) and the key. Someone who edits config.toml and is
silently ignored is the worst outcome this layer can produce.
| Variable | Effect |
|---|---|
OCUT_DB |
the tracker database (default ~/.local/share/opencode-usage-tracker/usage.db) |
OCUT_OPENCODE_DATA |
OpenCode's data home (default ~/.local/share/opencode) |
OCUT_OPENCODE_CONFIG |
OpenCode's config home (default ~/.config/opencode) — both the reader and the hook |
OCUT_HOST_DB |
the host database to read (default <data home>/opencode.db) |
OCUT_HOOK_EVENTS |
the plugin-usage sidecar (default ~/.local/share/opencode-usage-tracker/plugin-events.jsonl) — both the hook and ocut sync |
OCUT_CONFIG |
the settings file (default ~/.config/ocut/config.toml) |
XDG_CONFIG_HOME |
base for the default settings path |
OCUT_HOOK_DISABLE |
1 turns the hook off entirely |
OCUT_HOOK_PACKAGES |
where npm plugin sources are looked up for the hook's static scan (default ~/.cache/opencode/packages) |
OCUT_SCRIPTS |
override the scripts/ directory the launcher uses |
OCUT_VENV |
point the launcher at a venv dir or a python binary |
OCUT_CONFIG_DIR |
installer only: the OpenCode config directory to link into (default ~/.config/opencode) |
OCUT_BIN_DIR |
installer only: where the ocut launcher is linked (default ~/.local/bin) |
HOME |
inherited, not a knob: every default above is built from it |
ocut reads OpenCode's own state read-only and copies what it needs into its own
database; nothing is ever written back to OpenCode.
OpenCode's own state (read-only — never opened for writing)
~/.local/share/opencode/opencode.db session · message · part
~/.config/opencode/ installed skills · agents · commands · mcp · plugin
│
│ ocut sync → scripts/ocut-sync.py (opens the host with mode=ro)
▼
~/.local/share/opencode-usage-tracker/usage.db (SQLite — the tracker's own store)
│
│ reads
├── ocut → scripts/ocut-tui.py (Textual TUI, eight tabs, needs venv)
├── ocut stats|tools|… → scripts/ocut-stats.py (headless reports, stdlib only)
└── ocut backup|restore|vacuum|format
~/.local/share/opencode-usage-tracker/plugin-events.jsonl
▲ written only by plugin/ocut-hook.js, ingested by ocut sync
- Source — read-only. OpenCode's
opencode.db(session,message,part) plus the installed inventory under~/.config/opencode. The host database is openedmode=rowithPRAGMA query_only = ON, and a missing file is an error, not an empty success. - Sync.
ocut synccopies what it needs into the tracker's own database;--fullrebuilds it from scratch (after a snapshot). - Store.
~/.local/share/opencode-usage-tracker/usage.db— the tracker's own SQLite, written only byocut, in WAL mode with a 5-second busy timeout. - Readers. Every
ocutcommand. The TUI needs Textual (a venv); the headless reports need only the standard library.
Tables: tool_calls, model_calls, skill_usage, agent_usage, mcp_usage,
plugin_usage, plugin_surface, sessions, inventory, unparsed, sync_state.
Views: v_tools, v_skills, v_agents, v_mcp, v_plugins.
tool_calls— one row pertoolpart, whatever the tool is.categoryisbuiltin | skill | agent | mcp;statusis the host's ownstate.statusverbatim.skill_usage— theskilltool's calls; the skill name is read fromstate.input.name, one of the only two argument names this project looks at.agent_usage— thetasktool's calls (a subagent was started), keyed bystate.input.subagent_type.mcp_usage— MCP tool calls, found by name (<server>_<tool>) and resolved against the servers the host's config lists, longest prefix first.model_calls— one row perstep-finishpart; tokens and cost live here, the model comes from the parent message.sessions— one row per OpenCode session (including child sessions), with the host's own per-session rollup.sessionsandmodel_callsare one measurement at two granularities and are never added.inventory— what is installed, from the host's directories and config: skills, agents, commands, MCP servers, plugins, providers, and the tool ids each plugin registers.plugin_usage— one row per plugin tool/command call the hook saw. This is the one table fed from outside the host database.plugin_surface— the tool and command ids each plugin registers, as reported by the hook at load;ocut syncfolds it back intoinventoryafter the filesystem rewrite, and drops the rows of a plugin that is no longer installed.sync_state— one row per host table the reader tracks: the rowid cursor plus the host's owncount(*)/max(rowid)and anotewhere a pruned or renumbered table says so. The cursor is held back at the first non-finalrunningtool part so a call that finishes after the row was inserted is re-read; the hold is capped, so an interrupted session cannot pin the cursor forever.unparsed— records the indexer saw but could not use, keyed by reason. This is the runtime signal that OpenCode's persisted format moved.
Query it directly:
sqlite3 ~/.local/share/opencode-usage-tracker/usage.db \
"SELECT tool_name, calls, errors FROM v_tools ORDER BY calls DESC LIMIT 10;"
sqlite3 ~/.local/share/opencode-usage-tracker/usage.db \
"SELECT skill, calls, last_ts FROM v_skills ORDER BY calls DESC LIMIT 10;"ocut backup # snapshot the database now
ocut restore # list available snapshots
ocut restore NAME # restore one (close the TUI first)
ocut vacuum # compact the file, reporting what came back- Snapshots are written to
~/.local/share/opencode-usage-tracker/backups/asusage-YYYYMMDD-HHMMSS-<reason>.db, created with SQLite's online backup API — not a file copy, because under WAL the newest committed frames can still be in-wal, and a copied main file would be a stale database. - The newest
BACKUP_KEEP(5) managed snapshots are kept; older ones are pruned by mtime, not by name, because several can land in the same second. ocut sync --full, a schema-version bump, andocut restoreall take a snapshot first, so a destructive step is undoable.- Restore keeps the database that was there: it snapshots the live file before replacing it, so a wrong restore is itself reversible.
- Hand-made
usage.db.bak-*files beside the database are listed as restore sources but are never pruned — this tool did not create them and does not get to delete them.
There is no systemd timer: backups are manual and pre-destructive only. ocut backup
is the deliberate act.
ocut vacuum is the other deliberate one. It snapshots first (reason pre-vacuum), runs
SQLite's VACUUM, and prints the file before and after with the pages and freelist on each
side — then re-reads quick_check and every row count it printed before, because a rewrite
is the one step here that could lose a row. It is the only thing that returns the space a
dropped index leaves behind: dropping one parks its pages on the freelist instead of
releasing them, so the file stays the size it was until something rewrites it. Run it after
a schema change that drops something, or when the size stops matching the row count — never
on a schedule, since it rewrites the whole file and needs room for a second copy. Close
the TUI first.
| Symptom | Cause / fix |
|---|---|
no database at … — run: ocut sync |
The tracker database has not been built yet. Run ocut sync. |
no OpenCode database at … |
ocut cannot find ~/.local/share/opencode/opencode.db. Check that OpenCode has run, or point OCUT_HOST_DB at it. |
Plugin usage stays empty / a plugin shows 0 uses |
Plugin usage is recorded only by the hook, and only after an OpenCode restart. Confirm ~/.config/opencode/plugin/ocut-hook.js exists (it is a symlink), restart OpenCode, and check that OCUT_HOOK_DISABLE is not set. A 0 uses means "the hook recorded nothing", not "the plugin was not used". |
ocut: this database is older than this build |
The database predates the current schema. Run ocut sync to roll it forward. |
ocut health shows an UNPARSED line |
The reader met a record type or field it does not know — usually right after an OpenCode upgrade. The reason string names it; see compatibility. |
ocut health reports settings NOT READ |
~/.config/ocut/config.toml has an unknown key, malformed TOML, or an out-of-range value. The message names the key. |
| The TUI refuses to start | It needs a real terminal and a venv with textual. Run ./install.sh, or set OCUT_VENV to a python with textual. |
The list below runs M1–M17. The Chinese README carries the same list with more detail in §9.
- M1 OpenCode's own database is the source of truth.
ocutcan only see what the host persisted; a call the host never wrote apartfor is invisible, not misrecorded. - M2 The host database and config are read-only and never written — opened
mode=rowithPRAGMA query_only = ON.ocuttherefore cannot repair or backfill the host, only index it. - M3 Plugin usage requires the opt-in hook and an OpenCode restart. Until then
plugin_usageis empty, and a0 useson the Plugins tab means nothing recorded, not "not used". - M4 The hook's attribution is a best-effort static scan of each plugin's source.
A plugin whose surface the scan cannot parse records nothing — honest, because a
guessed owner would be worse than a missing row. New or renamed plugins need a restart.
A few plugins are recorded not at all by name (
ocut_db.UNTRACKED_PLUGINS, currently the desktop notifier): one that registers no tool and no command can never produce a usage row, so listing it would only hold a permanent0 usesthat reads like "never used". The hook skips it and the reader drops anything a sidecar written earlier still names. - M5 A plugin call's
tsis the hook's own clock, not the host's, and its status is NULL: a tool's after-hook carries no status and a command has no after-hook at all. - M6 When OpenCode's persisted format moves, the reader counts the record as
unparsedinstead of guessing. A jump inocut health'sUNPARSEDline after an upgrade means the format changed, not that you stopped using a tool. - M7 No message body and no tool prose is ever stored. A part's
output,title,metadata,error, atext/reasoningbody, and a sessiontitleare named nowhere the reader reads them; only identifiers, statuses, durations and counts land in the database. - M8 The per-step
model_callsand the host's per-session rollupsessionsare one measurement at two granularities. They are shown beside each other and are never added. - M9 MCP calls are attributed by name against the host's own config, resolved
longest prefix first. A tool name no configured server owns is not guessed into a
server; it stays
builtinand is reported as server-shaped-unowned. - M10 The plugin-usage sidecar is capped and rotated by truncation (8 MiB). The reader notices the file is shorter than its cursor and re-reads from the start — idempotent, because every record is keyed by its own id.
- M11 The Plugins tab is the union of what is installed and what has usage, keyed
by name. An installed-but-unused plugin shows a zero count; a plugin with usage and no
inventory row still appears. The list is not windowed — an installed plugin stays
listed at every range — while
usesandlast usedfollow the tab's window, like every other entity tab. - M12 Backups are manual and pre-destructive only. There is no systemd timer any more; retention keeps the newest five managed snapshots and never touches hand-made copies.
- M15 The
statecolumn has four labels, and the fourth means no evidence. OpenCode's builtin tool and agent ids live in its compiled registry: there is no file on disk that enumerates them, and the API that would answer needs the server running. So on Tools and Agents, a name with history and no inventory row readsnot reported, notremoved— this reader will not call a builtin deleted. On Skills, Plugins and MCP the inventory is a complete census, soremovedthere means what it says. - M16 Only the two global external skill roots are read:
~/.claude/skillsand~/.agents/skills. OpenCode also loads skills from each project —.claude/and.agents/walked from a session's directory up to its worktree root — plus~/.opencode,$OPENCODE_CONFIG_DIRand theskills.paths/skills.urlsconfig keys. None of those are inventoried: the first needs a session directory to start from and the last downloads something this reader does not do. A skill installed only at project level therefore does not appear as installed. Put shared skills in one of the two roots above and they will. - M13 The Tokens and Usage lists show a
Cache misscolumn and aUsage Totaleven though the host publishes no such fields: astep-finishpart carries only an uncachedprompt_tokensplustokens.cache.read/tokens.cache.write. The labels mirror the sibling CodeBuddy tracker, so this is a re-labelling, not new measurements —Cache missis the uncached input,Cache hitis the cache read,Inputis all three added (the full prompt), andUsage Totalis the display-onlyInput + Output + cache hit.API Totalstays the host's own step total; read the derived numbers beside it, never instead of it. - M14 The
statecolumn readsinstalledwhen there is no evidence to the contrary, and that is what it means on the Plugins tab. Usage there is written only by the optional hook (M3), so an installed plugin whose hook has recorded nothing carries exactly the same label as one that was installed and never touched. The label is deliberately about the inventory row, not about usage: nothing in this column is a claim that the thing ran. - M17 The TUI's tab-level columns, panels and labels mirror the sibling
CodeBuddy tracker's, but the two pushed detail screens and the status line keep
ocut's own fields: OpenCode names no plugin
target/marketplaceand reports no separate cache hit/miss besidecache_read, so the plugin-history and model-response tables cannot be column-for-column identical without inventing a field.
opencode-usage-tracker/
├── README.md # this file (English)
├── README.zh-CN.md # full documentation, Chinese
├── MAINTENANCE.md # operating checklist (English)
├── MAINTENANCE.zh-CN.md # operating checklist, Chinese
├── AGENTS.md # the agent contract (architecture, invariants, CI)
├── LICENSE # MIT
├── install.sh # idempotent installer
├── requirements.txt # textual (the TUI's only dependency)
├── requirements-dev.txt # + pytest
├── bin/ocut # bash dispatcher (location-independent)
├── plugin/ocut-hook.js # the one optional OpenCode plugin — writes the sidecar
├── scripts/ocut_db.py # shared data layer: schema, settings, queries, snapshots
├── scripts/ocut-sync.py # the passive reader: host database → tracker database
├── scripts/ocut-tui.py # Textual TUI (eight tabs)
├── scripts/ocut-stats.py # headless reports (stdlib only)
├── scripts/tests/ # pytest suite
└── docs/maintenance/ # compat matrix · host interface · measurements
python3 -m pytest scripts/tests -q # standard library only; TUI tests
# need textual
.venv/bin/python -m pytest scripts/tests -q # full suite, incl. TUI tests
.venv/bin/python -m pytest scripts/tests -q -n auto # the same suite in parallel
# (pytest-xdist; timing in
# docs/maintenance/measurements.md §6)Before operating this on a machine you care about, read
MAINTENANCE.md (Chinese: MAINTENANCE.zh-CN.md): the checks to run, the
invariants with the test guarding each one, and the deviations that must not be "fixed"
back. Behind it sit three references:
docs/maintenance/compatibility.md records the pins
and which OpenCode release each claim belongs to,
docs/maintenance/opencode-interface.md lists
every host name this project reads, and
docs/maintenance/measurements.md holds the command
behind every size, rate and duration quoted anywhere in this repository.
rm ~/.local/bin/ocut
rm ~/.config/opencode/plugin/ocut-hook.js
# the venv, if you installed it
rm -rf .venv
# data — back it up first
rm ~/.local/share/opencode-usage-tracker/usage.db*
rm -rf ~/.local/share/opencode-usage-tracker/backups
rm ~/.local/share/opencode-usage-tracker/plugin-events.jsonlocut never writes anything under ~/.config/opencode except through install.sh's one
symlink, so removing that link and the launcher is a complete uninstall.
Issues and pull requests are welcome. Before opening a PR, make sure the suite is green:
python3 -m pytest scripts/tests -q # standard library only; TUI tests need textual
.venv/bin/python -m pytest scripts/tests -q -n auto # the same suite in parallel
# (needs pytest-xdist; timing in
# docs/maintenance/measurements.md §6)Keep changes scoped, and update both README.md and README.zh-CN.md when you change
documented behaviour — scripts/tests/test_readme.py asserts that both READMEs carry the
same limitations, the same tabs and the same keys.
MIT © 2026 SHADE-glitch