Files
Maven/CLAUDE.md
T
claude a926383827 Wire staticcheck and deadcode, and gate both on a baseline (V-694)
The 2026-08-10 audit asked for three analyzers. V-682 wired the first as `make
vuln`. The other two were still absent: neither was installed on the box and no
target ran them, so every reachability claim in the audit stood unchecked.

`make lint` runs staticcheck v0.7.0 and `make deadcode` runs deadcode v0.48.0.
Both are pinned in the Makefile beside GO_VERSION and installed into deps/bin
the way govulncheck is, because a tool is not a dependency of the module. Both
carry the CGO env `test` carries, or the four CGO daemons fail to load and the
analyzer reports a build error instead of a finding. `make analyze` runs all
three. None joins `make test`: they install over the network and `test` has to
pass on a box with no route out.

Neither reports zero, so neither fails on its own output. staticcheck finds 20
and deadcode finds 13, and the audit asked for an allowlist by name, because
three of deadcode's eleven production symbols are deliberate and an unannotated
list invites deleting them. The accepted set lives in
scripts/analyzers/*.baseline, one line per finding with the reason it stays, and
scripts/analyzer-gate.sh gives the verdict. A key holds file, check id and
message, never a line number: a line number goes stale on the next edit above
it, and a gate that reports moved findings as new ones teaches the reader to
skip it. An entry whose finding is gone also fails, so a fix that leaves its
line behind does not pass.

deadcode runs with -test, because a test is a caller. Without the flag the
report is 172 lines, most of internal/router/eval, and none of it is a mistake.
With it, the 11 symbols the audit listed come back exactly, plus two test
helpers it did not count.

Three staticcheck findings were checked and are false positives, recorded as
such: the iCal determinism test must call RenderICal twice, the morning hedge
loop breaks after the first rune on purpose, and the SA9009 line is prose about
//go:embed with the real directive below it. One is V-687 already. The remaining
17 are V-701 with the judgement on each.

The analyzers caveat is deleted rather than edited. What replaces it is the
limit that is now true: the gates are green against a baseline, not against
zero.
2026-08-11 20:01:54 +04:00

12 KiB

CLAUDE.md

Guidance for Claude Code (claude.ai/code) working in this repository.

This is a rules file. It loads into every session, so it carries only what changes what an agent does. A measurement belongs in docs/evals/, dated and never edited after the day. A subsystem's reasoning belongs in its living doc under docs/. Read that doc before changing the subsystem.

Read this Before
docs/routing.md touching internal/router/ or queryWalk
docs/deployment.md touching a daemon, compose, a systemd unit or the web UI
docs/offload.md touching a daemon seam or adding a model caller
docs/world.md touching search, Kiwix or the world chain
docs/language.md changing a prompt contract or a Russian word list
docs/ecosystem.md touching Nexus, Praxis or Hexis
docs/rearchitecture.md, docs/design.md changing the shape of anything
docs/workflow.md the five stores, the doc tiers, the guards
docs/caveats/ a known limit, its task id and its revisit trigger
docs/CLAUDE.md which tier a doc belongs in, and what each one holds
AGENTS.md local preview, screenshots, model downloads

What Maven is

A self-hosted, privacy-first voice assistant in Russian and English. Go daemons talk over unix sockets. One resident small model routes and phrases. whisper.cpp does speech-to-text and piper does text-to-speech.

The resident model is Qwen3-1.7B (UD-Q4_K_XL) on homesrv, a Thinking variant at n_ctx 4096. Keep it at 1.7B or under. Sub-500M models are unusable in Russian (docs/evals/2026-07-31-model-bakeoff.md). Model files live in /mnt/hdd1/llms, bind-mounted over the repo's models/llm/, so a gguf sitting in the repo is loaded by nothing.

The workstation is workpc and it holds the remote model and speech-to-text. It is never assumed up. Fall back silently when it would only do the job better. Name the gap when the resident model cannot do the job at all.

The embedder stays on homesrv permanently, because it backs that floor. EmbedQuery and EmbedPassage apply the query: and passage: prefixes multilingual-e5-small was trained with. Calling plain Embed on a note is a bug.

Build and test

CGO daemons (mavend, mavsttd, mavttsd, mavenclient) need the vendored toolchain wired through the Makefile. Do not call go build on them bare, and do not hand-write the CGO preamble. This box runs zsh, so an unquoted -run Test* dies on "no matches found" before go is reached. make t also carries -count=1 and sets MAVEN_ONNX_LIB. Without that variable the four TestONNX* measurements self-skip and the run still prints ok.

make build   # all 11 binaries. make build-web for one (web/waked/poll/caldav skip CGO)
make test    # go test -race across ./internal/... ./cmd/... with CGO env set
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1   # V=1 for -v, RACE=0 to drop -race
make analyze # staticcheck, deadcode and govulncheck. Not in `test`: all three need the network

The static gates pass against a baseline, not against zero (scripts/analyzers/*.baseline, reasoning in docs/workflow.md). A fix must delete its baseline entry, because the gate also fails on an entry whose finding is gone. make audit is a git-grep inventory, not analysis. Do not cite it as a reachability check.

The daemons

Eleven binaries under cmd/, wired socket-to-socket over internal/ipc, not linked. mavend is the core and owns the DB and the IPC socket. deploy/mavend.json sets sockets, model paths and the phraser and embedder blocks, with ${VAR} expansion from gitignored deploy/telegram.env. docker-compose.yml runs five: mavend, mavsttd, mavttsd, mavweb, mavpoll. Count against compose, not against make build. mavwaked runs on workpc under systemd. docs/deployment.md says who else is absent and why.

  • Passwords are read from files, never taken as flag values.
  • The voice wire is plaintext with no auth. mavend's voice port stays on homesrv loopback and reaches workpc over ssh. Do not LAN-bind it. SurfaceVoice caps acts at L0, and L0 does not cap reading.
  • A GPU service added beside mavgpud goes in cmd/mavgpud, never in systemd. The card needs one owner. A second unit made mavgpud evict llama-server every few seconds and took the model arm down for eight minutes.

The ecosystem: Nexus, Praxis, Hexis

Nexus identifies, Praxis observes, Hexis acts, Maven understands. Maven does not own identity, operational state, or execution. Full contract in docs/ecosystem.md. All three are nil unless configured and each degrades alone. An outage means a named gap, never a broken turn or a guess.

  • No component reads another component's database. Praxis attention comes over HTTP, never from its SQLite file.
  • Identity lives in Nexus. Do not invent a local fact key for something Nexus resolves. cmd/mavend/factenrichment.go resolves actionFact.Subject.
  • Free text never reaches a mutating Hexis call. Resolve to a canonical entity id first. Ambiguous resolution asks the owner, it does not pick.
  • LLM output is not authorization. Confirmation binds capability id, target entity, arguments, requester and expiry (cmd/mavend/confirm.go).
  • Praxis lifecycle words differ. Surfaced is not acknowledged, acknowledged is not resolved, execution success is not recovery. Reading an item aloud calls Surface, never Acknowledge.
  • No automatic attention-to-action path. Digestion may summarise Praxis and may not call Hexis.
  • Every cross-service call carries a correlation id minted once per action (withCorrelationID), a contract version header, and X-Requested-By: maven.

Routing

Read docs/routing.md before touching internal/router/ or queryWalk. It carries the reasoning, the measurements and every rule's why. A route produces two decisions. Intent is one of seven values. Source is where the answer lives and is read on IntentQuery alone. Score them separately. The cascade is stage 0 grammars, then the routing heads, then the resident model, then the classifier. Every stage may decline and the next one answers.

  • The classifier is the floor, not dead code. It answers when the resident model is off, absent, or erroring. Any model error falls through.
  • baselineGrammars in eval_test.go mirrors buildRouter. A grammar added to one belongs in both, or the fixture scores a set nobody runs.
  • Go's \b is ASCII-only and never fires after a Cyrillic letter. A Russian pattern needs an explicit (\s|[?!.]|$).
  • PraxisGrammars() is the only path to Praxis, not a faster one.
  • voice.embedder.heads_path must never point at model_path. Recall depends on the resident e5-small scoring what it scored. Fine-tune a copy.
  • Routing traces are retained 14 days, enforced on write and again on start.
  • Bump tokenizerRev on any change to what encodeWord emits, so a tokenizer fix triggers ReembedAll the way swapping the model file does.
  • A new rung in the runTurn ladder needs its name in preRouteLadder (cmd/mavend/decisiontrace.go), or it is missing from the decision record.

queryWalk takes query sources out and moves none (actions_query.go). That is the safety argument and it is not negotiable. The table's order is load-bearing and carries "the owner's data first, then the world". SourceUnknown is the floor and walks the whole chain. A named destination removes only the sources marked guesses: true, so a source that looks rather than guesses is always asked. The personal boundary is the one exception and it is deliberate. It guesses, so naming SourceWorld drops it. Only a stage 0 grammar may drop it (owner's call, V-666). queryWalk reads Decision.SourceAnchored for the source marked boundary: true and no other.

Judge a routing change against the classifier (76.0% intent, 36.4% destination) and the resident model (80.2% intent), since those always answer. The fixture has grown from 77 cases to 96, so a number compares only to another number on the same fixture.

Language: model output and Russian

Both contracts are in docs/language.md. What must not be broken:

  • One parser for model text, parseResponseMood in internal/phraser/parse.go. Every phrasing path reaches it. Mood is an enum.
  • The router prompt is a separate contract over 7 intents, and llm/check_prompt_parity.py keeps the Go and relabelling copies identical.
  • Russian words are matched by three mechanisms and no fourth: internal/lexicon for closed classes, internal/morph for grammar, and cmd/mavend/topics.go with the embedder for open sets. A regex whose output is a fact or a route is the defect. A regex over structured input is not.
  • Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the TestONNX* tests, not eyeballed.

Non-goals and hard constraints

Not a nag, not autonomous. The persona is feminine. Russian self-reference takes feminine forms: рада not рад, поняла not понял. The owner is male and she speaks to him informally. Use "ты", singular, never "вы" or "ваш", and never "он" or "его". She talks TO the owner, not about him. Pet names such as "милый" are forbidden. The name "Ками" is not. CheckAddress, CheckFeminine and CheckCringe in internal/phraser/eval/checks.go enforce this, scored by make eval-phrasing.

"Never phones home" is deprecated (owner's call, 2026-07-31). She reads external sources, and docs/world.md carries that chain. What holds regardless:

  • No telemetry, no cloud model, no third-party account. Inference stays on the box and nothing about Maven is reported to anyone.
  • The owner's data first, then the world. Every source reading his facts, notes, calendar, tasks or house runs first, and the personal boundary sits between them and anything outside.
  • His notes and facts are never search input. Only the utterance goes out, never the persona block, the history, or matched notes.
  • External search is allowed and off unless configured. Deleting the search block in deploy/mavend.json turns it off.
  • Response.Empty() is the whole gate on a world answer. There is no quality threshold in front of it and four candidate signals all failed.

Session workflow

docs/workflow.md carries the five stores, the doc tiers and the guards. One task, one session, one PR. /pickup opens a session and /wrap closes it. Wrap at roughly half context rather than letting the session compact.

task start <vikunja-id>   # branch off origin/master, write TASK.md, fetch review comments
task pr                   # push, open or refresh the PR, label Vikunja, notify
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
  • This repo is Vikunja project Maven (ID 2), MCP at http://localhost:9100/mcp, or http://192.168.1.104:9100/mcp from workpc.
  • A session with no task id asks for one before it starts, because work without one is work nobody can resume.
  • Close a finished task with done: true and nothing else (owner's call, 2026-08-07). update_task carrying a description resets done to false.
  • pre-commit refuses master and more than 300 changed lines in non-markdown files. Markdown is exempt and may land as one batch.
  • commit-msg requires the subject to end with (V-<id>). V- and not #, because Gitea autolinks #123 to the wrong tracker.
  • diff-budget.sh blocks edits past 600 changed lines on a task/ branch.
  • --no-verify exists. Using it means saying why in the commit body.