6b2667b7af
The four schemas are deferred, so a session that looks them up on first use spends four round trips on tools it always needs. One ToolSearch line at the start covers them. Also records the update_task quirk: a call carrying a description resets done to false, so closing a task with a write-up takes two calls.
406 lines
26 KiB
Markdown
406 lines
26 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons
|
||
talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS.
|
||
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||
|
||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
||
talk fixture. See `docs/evals/2026-07-31-model-bakeoff.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
||
|
||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||
Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`,
|
||
masculine, where Maven needs `рада`. That is what the CPT is for.
|
||
|
||
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and
|
||
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
|
||
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
|
||
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
|
||
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||
|
||
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
|
||
`AGENTS.md` for local-preview + model-download recipes.
|
||
|
||
**Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a
|
||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
||
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
||
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
||
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
|
||
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
|
||
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
|
||
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
|
||
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
|
||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||
are the work.
|
||
|
||
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
||
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
|
||
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
|
||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner hears instead of an invented
|
||
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
||
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||
|
||
## Build & test
|
||
|
||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||
|
||
```sh
|
||
make build # all 9 binaries
|
||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||
```
|
||
|
||
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
|
||
|
||
```sh
|
||
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
|
||
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
|
||
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
|
||
deps/go/go/bin/go test -run TestName ./internal/router/
|
||
```
|
||
|
||
Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`.
|
||
|
||
## The daemons (`cmd/`)
|
||
|
||
| Binary | Role |
|
||
|---|---|
|
||
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. |
|
||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||
| `mavpoll` | Telegram long-poll reach. |
|
||
| `mavcaldav` | CalDAV calendar sync. |
|
||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||
|
||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||
|
||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||
daemon there listens to nobody. They belong on a client machine where the owner is standing.
|
||
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
|
||
|
||
## The ecosystem: Nexus, Praxis, Hexis
|
||
|
||
Maven is one of four services. It owns conversation and personal memory. It does not
|
||
own identity, operational state, or execution. Full contract in
|
||
`docs/ecosystem.md`.
|
||
|
||
```text
|
||
Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates.
|
||
```
|
||
|
||
| Service | Owns | Maven's client | Configured at |
|
||
|---|---|---|---|
|
||
| **Nexus** | Canonical entity ids, names, aliases, relationships. Projects, services, devices, people, pets, places. | `nexusClient` in `cmd/mavend/ecosystem.go`, `POST /api/v1/resolve` | `nexus.url` (`http://nexus:9740`) |
|
||
| **Praxis** | Operational attention and item lifecycle. What needs looking at, what changed, what is still unresolved. | `praxisClient`, the HTTP tools API under `/api/v1/tools/` | `praxis.url` (`http://praxis:8989`) |
|
||
| **Hexis** | The capability registry and the only path to executing anything. | vendored `github.com/kami/hexis/pkg/client` | `hexis.url` (`http://hexis:9741`) |
|
||
|
||
All three are `nil` unless configured, and every one of them degrades on its own.
|
||
An outage means a named gap in the answer, never a broken turn and never a guess.
|
||
|
||
Rules that are not negotiable:
|
||
|
||
- **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. `actionFact` already sets `Subject`, and `cmd/mavend/factenrichment.go`
|
||
resolves it in the background against Nexus.
|
||
- **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. See `cmd/mavend/confirm.go`.
|
||
- **Praxis lifecycle words mean different things.** 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. It 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 this before touching the router
|
||
|
||
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||
2026-07-31.
|
||
|
||
- **LLM router (the intended design, docs/rearchitecture.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||
- **Classifier cascade (the failure floor, not dead code):** `classifier.go` +
|
||
`embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is
|
||
off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades),
|
||
and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known
|
||
cause of weak RU query handling, but a turn must never break on the model.
|
||
|
||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||
|
||
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
||
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
|
||
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
||
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
|
||
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
|
||
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
||
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||
work off the bakeoff table.
|
||
|
||
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
||
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
|
||
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
||
routing change against the classifier and the resident model, since those are what always answer.
|
||
|
||
**The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546,
|
||
`docs/plans/18-routing-heads-on-e5-small.md`). Routing has a bounded output space, so it is
|
||
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
|
||
forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30
|
||
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things
|
||
it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value
|
||
that does not exist. And max softmax is a calibratable confidence, where `Confidence: 1.0`
|
||
was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs memory
|
||
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
||
suite to name the trade.
|
||
|
||
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||
`gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never
|
||
consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway.
|
||
|
||
Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false
|
||
clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the
|
||
three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly
|
||
executing, so the fixture and the daemon disagree about what is correct there. The third,
|
||
`"поужинал"`, was a real defect: the single-token rule was an English intuition and does not
|
||
transfer to Russian, where one word is routinely a whole sentence.
|
||
|
||
Narrowed 01-08-2026. `thinSingleToken` (`internal/router/singletoken.go`) still thins a bare
|
||
one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and
|
||
control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb
|
||
ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both
|
||
tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, intent-only 74.0% →
|
||
75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false
|
||
clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule.
|
||
|
||
Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired
|
||
after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня
|
||
встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to
|
||
`IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the
|
||
fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% →
|
||
72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that
|
||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||
explicit `(\s|[?!.]|$)`.
|
||
|
||
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
|
||
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
|
||
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
|
||
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
|
||
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
|
||
latency and fixture defect, not a correctness one. The narrative rule reads the same
|
||
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
|
||
joke, a bedtime story, herself — because the query chain has no source that answers those.
|
||
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
|
||
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||
llama-server in that run), so judge it again before quoting a cascade number.
|
||
|
||
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
|
||
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
|
||
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach
|
||
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
|
||
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
|
||
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
|
||
(`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative**
|
||
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
|
||
**imperative** ("закрывай") may ask which one. The bare arm additionally requires that
|
||
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
|
||
of the house. A demonstrative ("отметь это как сделанное") resolves against
|
||
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
|
||
the cascade rather than transitioning the wrong item.
|
||
|
||
## LLM output contract
|
||
|
||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||
workspace enforces that the Go and relabelling prompts remain identical.
|
||
|
||
## Russian patterns — three mechanisms, no fourth
|
||
|
||
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a
|
||
pattern, and the resident model cannot be asked per turn either). A regex whose output is a
|
||
fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an
|
||
argv list — is not. Before writing a Russian word list, pick one of these:
|
||
|
||
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
|
||
spoken hours. Editing a word is a data change, and there is exactly one copy: months used
|
||
to live in three files. Cardinals carry the oblique forms, because a spoken time declines
|
||
and `в семь` / `к семи` are one hour.
|
||
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||
lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`).
|
||
- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a
|
||
turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the
|
||
turn's own query vector. Same shape as the personal boundary in `personalboundary.go`,
|
||
with one difference: a topic must clear the runner-up by `topicMargin`, because a false
|
||
claim here spends a network scan rather than one honest "не знаю". The old keyword tests
|
||
stay as the offline floor and may remain narrow, since they are no longer the only answer.
|
||
- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is
|
||
Nexus's, never a local pattern.
|
||
|
||
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
|
||
`TestONNX*` tests, not eyeballed.
|
||
|
||
## Non-goals (hard constraints)
|
||
|
||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||
(she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name
|
||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||
|
||
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||
world questions, so she needs to read external sources. What replaces it:
|
||
|
||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||
about Maven is reported to anyone, and inference stays on the box.
|
||
- **The owner's data first, then the world.** Every source that reads the owner's facts, notes, calendar,
|
||
tasks or house runs before anything outside, and the personal boundary sits between them.
|
||
Reading beats recalling for a small model.
|
||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
||
2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on
|
||
homesrv answer when the search is empty, unreachable, or the line is down.
|
||
**Verified with the line down on 2026-08-05** (V-508,
|
||
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
|
||
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited
|
||
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
|
||
instance that did connect keeps the full 8. **A Russian question reads
|
||
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
|
||
is the workaround for an English book and is skipped there. Kiwix catalog names come
|
||
from the filename, not the `<name>` field.
|
||
`Response.Empty()` is the whole gate and there is no quality threshold in front of it:
|
||
the three signals one could read were measured on 2026-08-05 and none of them separate a
|
||
real question from an invented one. Token overlap would cost "столица Франции" its
|
||
answer, because the answer is Париж and that word is not in the question. See
|
||
`docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed
|
||
a turn is readable on `/chat`** as a badge beside the reply, carried on
|
||
`ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It
|
||
rides the context, so `handleText` keeps the one string signature the mic, telegram and
|
||
the web share.
|
||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||
it off again.
|
||
- **The owner's notes and facts are never search input.** Looking up why the sky is blue and
|
||
sending the owner's stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||
out, never the persona block, history, or matched notes.
|
||
|
||
## Web UI conventions
|
||
|
||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
||
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
||
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
||
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||
|
||
## Vikunja
|
||
|
||
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
|
||
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
|
||
|
||
Vikunja is the durable task store. A task holds the goal, the constraints and the
|
||
assumption ledger. Work without a task id is work nobody can resume, so a session that
|
||
has no id asks for one before it starts.
|
||
|
||
The MCP tool schemas are deferred, so load the four you actually use in ONE call at the
|
||
start of a session rather than one lookup per first use:
|
||
|
||
```text
|
||
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||
```
|
||
|
||
`update_task` carrying a `description` resets `done` to false, so closing a task with a
|
||
write-up takes two calls: the description, then `done: true`.
|
||
|
||
## Session workflow
|
||
|
||
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
||
session, one PR.
|
||
|
||
```sh
|
||
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
|
||
task comments # re-pull this branch's review comments into .task/
|
||
```
|
||
|
||
Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half
|
||
context rather than letting the session compact.
|
||
|
||
Five stores, and each one owns something the others must not hold:
|
||
|
||
| Store | Holds | Lifetime |
|
||
|---|---|---|
|
||
| Vikunja task | goal, constraints, assumption ledger, status | durable |
|
||
| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable |
|
||
| `docs/` | design, measurements, decisions | durable |
|
||
| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch |
|
||
| `HANDOFF.md` | only what the next agent needs to resume | one session |
|
||
|
||
`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is
|
||
gitignored and injected at session start. If a line in the handoff would still matter
|
||
next week, it is in the wrong file.
|
||
|
||
Docs are tiered by path, so staleness is visible from the filename. Files directly under
|
||
`docs/` are living and carry a `Last verified: <date> @ <sha>` line. Files under
|
||
`docs/evals/` are dated measurements and are never edited after the day, so a newer
|
||
number is a new file. Files under `docs/archive/` are dead and read by nobody by default.
|
||
|
||
## Git guards
|
||
|
||
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
|
||
|
||
```sh
|
||
git config core.hooksPath .githooks
|
||
```
|
||
|
||
- `pre-commit` refuses master, and refuses 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 a Gitea issue, which is the wrong tracker.
|
||
|
||
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh` blocks
|
||
further edits past 600 changed lines on a `task/` branch. `prose_lint_hook.py` checks
|
||
prose on every write. Both measure against `origin/master`, so a local master that is
|
||
ahead of the remote makes the diff budget read high.
|
||
|
||
`--no-verify` exists. Using it means saying why in the commit body.
|