V-563. cmd/mavend/dialogue_contract_test.go holds twelve whole multi-turn
traces. Each turn asserts the reply, what is parked afterwards including the
attempt count, and the end state: reminders with payload and fire time, fact
keys, note count, task texts.
Six traces pass today. Six carry the correct expectation and skip, naming the
task that makes them green: the owner's transcript and its parseable twin
(V-561), cancel and a correction under a parked question (V-560), a whole
reminder still being asked about and a short correction (V-562).
MAVEN_DIALOGUE_NO_SKIP=1 runs the skipped rows, so a fixer sees their row turn
green and a stale skip is caught.
Offline: hash embedder, no llama-server, no ONNX. Failures print a claimant
trace derived from the daemon's log lines, so a wrong claimant reads
differently from wrong copy.
Each carries the correct expectation and is skipped with the task that
will unskip it, because a weakened expectation would pin the bug as the
contract. MAVEN_DIALOGUE_NO_SKIP=1 runs them.
V-561: the owner's transcript, and the same shape in words the offline
date parser reads — a side query drops the parked question instead of
suspending it, so Rome is never answered and the reminder is never set.
V-560: a cancel is scored as a failed answer and spends a retry; clarify
pre-empts the repair marker, so no correction can be spoken mid-flow.
V-562: a stage-0 reminder never meets the extractor, so a reminder said
whole with its hour in it is still asked about; and finishClarified goes
straight to applyAction, so a repaired decision that lands short answers
with a parse error instead of asking.
Six whole traces through the real cascade with no model: a reminder and a
fact each completed over two turns, an answer that arrives past the TTL,
three unclear answers and the give-up line, a correction of the previous
turn, and an abandoned flow. Each asserts the reply, what is parked after
every turn, and the end state of the store.
V-559. internal/dialogue gains PendingAction: capability, slots, missing
slots, TTL and attempt cap, with CapabilityFor as the one intent to
capability map. PendingQuestion derives its action rather than storing a
second copy, so the TTL and attempt rules have one implementation.
The clarify store now holds a bounded stack, MaxStackDepth 2. Behaviour is
identical: Put replaces the top, nothing calls Push, so the daemon runs at
depth one. Push returns what the bound evicted, so nothing dies silently.
Groundwork for V-560 and V-561.
Push/peek/pop including that a peek does not consume and that the flow
under a popped entry survives; that a popped entry stays gone; that a push
past MaxStackDepth returns the evicted entry rather than dropping it
silently; that Put keeps the depth at one; that an expired top takes the
stack with it and is reported once by TakeExpired; and that two dialogue
ids do not read each other's stack.
One parked question per dialogue id meant a side query could only destroy
the flow it interrupted. The store now keeps a stack per id, newest last,
with Push, Peek, Pop, Depth and Delete as drop-all. MaxStackDepth is 2:
one flow plus the thing he interrupted it with, because spoken
conversation does not nest deeper, and because every level she keeps is a
level she has to be able to speak when it dies.
Behaviour is unchanged. Put still replaces the top rather than growing the
stack — a re-ask is another question about the same action — and nothing
calls Push yet, so the daemon runs at depth one exactly as before. Get is
Peek under the name the callers already use. An expired top takes the
stack with it and TakeExpired reports it, so no parked action dies without
a word; Push returns the entry the depth bound forced out for the same
reason.
PendingQuestion.IsExpired and CanAsk now answer through PendingAction, so
the TTL and attempt-cap rules have one copy and the widening cannot drift.
A parked clarify said what she heard (an intent) and not what she was
about to do, so the resolver had to infer the action from conversational
history instead of reading it off an object. PendingAction names the
capability being assembled in the ecosystem's dotted form
(reminder.create, fact.write, act.run), the slots it has, the slots it
still wants, when it was asked, attempts and TTL.
Gaps() computes the missing slots from the slots rather than trusting
Missing, because Missing is what she asked and the slots are what she
got. CapabilityFor maps every dialogue.Intent, so the mapping lives here
and dialogue still does not import router (the cycle rule).
Nothing reads it yet: this is the widening V-560 to V-562 build on.
The clarify path was gated on dec.Clarify, so a turn the cascade routed
confidently but incompletely skipped it. "напомни позвонить" reached applyAction,
failed on the missing time and parked nothing, and the "в семь вечера" that
followed was routed as a world question and web-searched.
The gate now also fires when missingFor names a required slot. A bare capture
verb gets a stage-0 rule of its own: it was reaching the resident model as chat,
which answered by agreeing to a wording change nobody asked for.
askClarify parked "Что сделать?" whatever was on the other end. With an empty
allowlist that question has no answer: she asks, fails, asks again and gives up,
three turns spent on a request she could have declined in the first one.
Empty allowlist now names the gap and parks nothing. A non-empty one still asks,
and names what she can run, capped at six, so the question is answerable.
Three durable stores said no client machine existed. That was written
when the workstation was only a model host. It is where he sits most of
the day and it has the microphone.
The verdict is unchanged and so is the seam. What changes is the size of
the remaining work: deploying two daemons and asking mavend to listen on
TCP, not acquiring hardware. Note that deploying them does not by itself
prove a wake word — mavwaked gates on energy and has no keyword model
(V-487).
Measured on the box: "глаголы в прошедшем времени с окончанием -ла",
copied from the query prompt where it fixes her gender, was read by the
resident model as an instruction to use the past tense throughout. She
answered "я вела заметки" and "если ты разрешил, я управляла домом",
which makes a live capability sound finished.
The gender rule stays, without the example.
Two defects found probing the new source on the box.
"кто ты" was answered from one of his notes. The self source sat below
memory and notes, which match by proximity and have no idea the subject
is her. It belongs above all three: a question about her has no answer
in his data either.
And PhraseQuery opens every answer with "вот что я нашла: ", which is
deliberate — it marks the answer as a lookup. Her own description is the
one subject she did not look up, so this is PhraseSelf instead, same
read-only discipline and its own opener. The Stub reads the description
out as it stands, which needs no fallback: it is already her voice.
"что ты умеешь" reached the personal boundary, which claimed it as his
and said "не знаю — не нашла у тебя такой записи" about her own
description. Letting it past would be no better: SearXNG answers about
somebody else's assistant.
A self query source above the boundary, reading one frozen description.
It is NOT a note — notes are his, and a note about her would come back
for "что я записал", would be fed to the digestion worker as something
he said, and would be recalled by proximity for questions that are not
about her.
The description names only what this box does. Everything that depends
on config — the house, the LAN, the feeds, the list, weather, telegram —
is named as depending on what he allowed, and a test pins that split:
inventing a capability here is the same defect as inventing a fact.
topicSelf is scored like every other topic, with a narrow keyword floor
for the no-embedder case. "что ты умеешь" moved off topicOther, where it
had been sitting so an attention question had something to lose to — a
phrasing on two sides never clears the margin. TestONNXTopics 38/38 ->
43/43 on held-out utterances.
Found verifying the three fixes on the box: "кто изобрёл телефон" ran a
LAN scan and answered "нашла 3 устройства". The network seed set opens
with "кто в сети сейчас" and names devices throughout, so a "кто ..."
question about any device noun landed there.
Three topicOther seeds, same shape as the V-553 fix. TestONNXTopics
34/34 -> 38/38 on held-out utterances, and a real scan is still a scan.
Prior turns were joined with newlines and nothing else, so the model got
four unlabelled lines and no way to tell which one was the question. It
answered an earlier one: asked "как дела" after a question about the
telephone, she carried on about the telephone. Four turns live for
fifteen minutes, so the line she answered was often minutes old.
One user message still, because the template constraint that forced the
flattening is real. The turns are labelled as his own earlier words and
the current utterance is named as the one to answer. With no history
the message is the utterance alone, unchanged.
"расскажи про Байкал" was refused as his by 0.0052. Every world seed
opened with an interrogative, so a world question phrased as an order
landed nearer "я тебе рассказывал об этом?" — the same verb about his
own words. Four narrative seeds on the world side.
TestONNXPersonalBoundary 25/25 -> 29/29 on held-out utterances, and the
control "я рассказывал тебе про байкал?" is still his. TestONNXTopics
unchanged at 34/34.
A parked question consumed whatever came next. One act she could not
fulfil ate three turns: "выключи свет в спальне" asked "Что сделать?",
and "кто изобрёл телефон" was scored as an answer to it, then "как
дела" after that. Nothing tested whether the words could be an answer.
The test is two offline token checks that already existed for other
callers: a question shape, or a capture verb. It fires only where the
answer filled nothing, so an answer that closes the gap still lands
whatever shape it has, and the retry budget is untouched — the count
was never the problem.
The topic seeds let "какой сегодня праздник" and "что интересного
произошло сегодня в мире" past the weather source, and the personal
boundary refused them one source further down: "не знаю — не нашла у
тебя такой записи" about a public holiday.
Same defect, same mechanism, one layer lower. "что у меня сегодня" is a
personal seed and worldSeeds had nothing in that frame. Two seeds fix it.
TestONNXPersonalBoundary 22/22 -> 25/25, nothing regressed.
Two recognisers claimed world questions naming a day, both by the same
mechanism and neither by its keyword floor.
topics: weather was the only topic whose seeds carry a day word, four of
eight. So every "какой сегодня X" landed nearest it. "какой сегодня
курс доллара" cleared the margin by 0.0220 and "какой сегодня
праздник" by 0.0398, against 0.0883 for a real weather question, and the
gate asked "для какого города?" about the dollar.
The margin was not the knob: 0.0398 is not a coin flip, and raising the
bar far enough would take real weather with it. topicOther was missing
the negative class. Six seeds, four naming a day and two carrying the
"какой сегодня X" frame itself — a frame both topics use has to sit on
both sides, or the side that owns it wins every noun it has never seen.
personal boundary: the same shape one layer down. "что у меня сегодня"
and "когда моя встреча" put "when does a thing happen" on the
personal side and no world seed answered it, so "во сколько закат
сегодня" was refused as his. Three world seeds, each carrying сегодня,
which is the half of the frame that does the pulling — without it they
caught nothing.
Measured, both opt-in against the ONNX embedder homesrv runs:
TestONNXTopics 27/27 -> 34/34 (7 new cases, none regressed)
TestONNXPersonalBoundary 19/19 -> 22/22 (3 new cases, none regressed)
The control matters as much as the fix: "во сколько у меня встреча" is
the same frame about something that IS his, and it holds at +0.0842,
unchanged from before the seeds moved.
Replaces a test whose name promised more than its body checked: it
looped the grammars asserting Pattern != nil, which regexp.MustCompile
already guarantees at init. Asserting the grammars pass IsAgendaQuestion
would be true by construction, since the first arm is that same loop.
A hand-written example per grammar name catches what neither does: a
grammar edited until it no longer matches the case its comment gives,
and a new grammar nobody wrote an example for.
queryCalendar matched on a day word and stepped aside only on weather
wording. Every world question naming a day was claimed by it and answered
with an empty schedule: "какой сегодня курс доллара" replied "на
05.08.2026 ничего нет", which reads as an answer about a subject she
never looked at. All four probe utterances have an answer in search, and
search sits below the calendar.
V-474 fixed one instance of the class. Sunset, holidays, exchange rates
and world news are the same class and weather wording does not cover them.
router.IsAgendaQuestion is the narrowing. Its first arm reuses
AgendaQueryGrammars, so the rule that routes a question to the query
chain and the rule that lets the calendar answer it cannot drift. The
second reads a scheduled-thing noun, wider than the grammars because
"какие встречи завтра" carries no possessive. The third claims a
question that names no subject of its own.
A continuation is exempt: "а завтра?" cannot name an agenda, and this
is the only date-aware source there is.
Item 2 was blocked because the resident llama-server binds --port 0 inside the
container, so no host process can reach it. Cleared by taking the first of the
three ways out the task listed: a second llama-server on the same gguf, on a
fixed host port.
Cascade + resident model scores 75.8% full and 80.2% intent-only at p50 1.19s
and p95 1.65s, on the fixture as it now stands at 91 cases. That is a new
baseline rather than a movement: 14 cases were added since the 77-case number
in CLAUDE.md.
The model alone scores 37.4% full against 61.5% intent-only. The gap is slots,
not routing. Every reminder case leaves the time to the daemon, which is what
the contract asks of it, and the cascade fills them.
Item 3: the ~6s figure recorded in the task was one sample through the whole
of POST /api/chat, not the router, and is not comparable.
Item 4 is still not run. Killing the resident llama-server needs a permission
this session does not have, and it now has a second half anyway, since with the
workstation up only killing both proves the classifier answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
At 21:12 "напомни мне завтра в семь позвонить маме" confirmed a reminder for
21:12 tomorrow. The hour was dropped and the wall clock carried onto the named
day. She did not ask; she named a time nobody gave her, on a path that fires.
A bare "напомни в семь" declines correctly, so adding "завтра" turned a decline
into an invented answer.
dateparser only reads a bare hour when it carries a qualifier or a colon.
"завтра в 7" keeps the current clock and "завтра в 7 часов" is read as seven
hours from now, which moves the day as well. English "at 7" fails identically,
so this is not a Russian defect and both prepositions are rewritten.
The script now gives it the colon: "в 7", "в 7 часов" and "at 7" become
"в 07:00" beside the existing утра/вечера rewrites. A duration is untouched,
because "через 2 часа" has no preposition to match, and so are "в 7:30",
"в 30 минут" and "в 2026 году".
The stub parser has always read the token after the day word, so the floor was
right and the production parser was not. No test on the stub could have caught
this. The four new cases are in TestPythonDateParser, which runs where
dateparser is installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
V-405 measured reach with the classifier only, and the LLM router is the
deployed default, so 16/30 was the floor rather than the shipped behaviour.
TestReachWithLLMRouter scores the same 30 cases with the model, gated on
MAVEN_LLM_URL like TestLLMRouterBaseline.
The open question was whether the model writes a literal Praxis capability
into the fn slot and reaches a service the classifier structurally cannot. It
does not. Praxis is 0/12 with the model alone, exactly what the classifier
alone scores, and all twelve fail the same way: local, empty fn. Nothing in the
router prompt names a Praxis capability, so there is no string for it to write.
So V-516's stage-0 grammars are the only path to Praxis, not a determinism
argument. Through the cascade the model scores 28/30 with praxis 11/12, one
point above the classifier baseline. Hexis is 10/10 either way.
Overreach is 1 in both configurations, under the 4 the harness asserts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
"что ты записала сегодня?" was recognised as a history question and then
answered with "ты говорил: …". The rows are right — a tapped fact is one act
seen from two sides — but the sentence hands the question back instead of
answering it.
historyAsks returns which side was asked and queryHistory phrases from it,
including the nothing-found reply. His side is tested first, because "отмечать"
is on both verb lists and "что я отметил" is not a question about her.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
V-494 part 1. Store.Wipe drops every table and rebuilds from the migrations;
mavend -wipe is a dry run and -confirm-wipe deletes. QA isolation and
onboarding are the remaining two thirds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
There was no documented way to repair a poisoned box. Two invented facts
written during QA disabled world answering for every later turn (V-470), and
revert voids the SQL row while leaving the vector behind (V-493). This is the
operation that undoes both.
Store.Wipe drops every table sqlite_master reports and rebuilds from schema.sql
plus the migrations, rather than deleting from a hand-written list. A list has
to be edited whenever a table is added, and the once it is not, the wipe leaves
personal data behind while reporting success. It vacuums afterwards, because
free pages still hold readable text.
mavend -wipe prints every table and its row count and exits. That alone is a
dry run and answers what a QA session actually asks: what is on this box. It
deletes only with -confirm-wipe. Two flags, because the destructive reading of
one flag is the reading a mistyped command gets.
Nothing outside the database moves. Config, models, passkeys.json and the
encryption key are files.
QA isolation and onboarding are the other two thirds of V-494 and are not here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
The candidate row is now an intake form, not a button. Confirming asks for a
definition of done and refuses without one, takes an optional blocked-on name,
and carries the date and the importance through.
The blocked-on is a name in the form and a canonical nexus id in the store.
promoteCandidate resolves it over the new ipc.ResolveEntity seam and stops the
confirmation on an ambiguous or unplaceable name rather than picking.
A date set here books a reminder for 09:00 that morning. That is the only
unprompted delivery the persona allows, because the owner set the date himself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
blocked_on stores a canonical entity id, so the form that fills it needs a way
to turn "Kate" into one. ipc.ResolveEntity is that seam: the store adapter
refuses it, because identity is not the store's to answer, and the daemon
overrides it with the Nexus client the voice path already holds.
Three outcomes are kept apart, because a caller deciding whether to store an
id has to tell them apart. No nexus block is ErrNotImplemented. A miss is
ErrNoEntity. Several matches come back Ambiguous with the names, and the
caller asks — picking one is how a task ends up blocked on the wrong person
with nobody able to see it happened.
An outage stays the transport error. "There is no such person" and "Nexus is
down" must not read the same.
The open list carries the text, the date and the importance as an inline form
with a save button. The status is not in it: that ladder is one-way and has
its own two buttons.
The step-up gate was re-argued rather than inherited, which is what the task
asked for, and edit stays ungated. It rewrites a line on a list he reads
himself, the same blast radius drop already has here, and the store refuses
the two edits that would cost something. A collision is named ("another open
task already says this"), not merged.
A weight outside the three rungs keeps its own option in the select, or
saving an unrelated edit would silently reset it to normal.