chatHistory (voice.go) and rememberTurn (clarify.go) both built the same
dialogue.Turn{Intent, Slots, Text} projection of a *dialogue.Session
inline; factor it into sessionAsTurn and use it in both. Also name the
history-depth cap (previously a bare "3") as maxCarriedHistory, and fix
chatHistory's doc comment, which cited "lines 373-395" for the dialogue
merge in runTurn -- that block has since moved to lines 385-394. Point
at the step-6 comment instead of a line range so the reference survives
future edits. No behavior change; bookkeeping only, not the clarify/
reminder slot-decision logic.
stopFinishedAlarms and repeatableRules each built their own
map[string]rule (one keyed to loop.Rule, one to bool) from t.rules on
every call. Factored into rulesByName(), one map[string]loop.Rule both
callers read.
buildRouter's third bullet described a hardcoded 6-example bootstrap
set that predates seedClassifier's file-based loader; seedClassifier's
own comment named 5 seed files where there are 7 (chat.txt and
system.txt were missing). Also named the repeated 2*time.Minute
dialogue session TTL literal as dialogueSessionTTL so the two call
sites can't drift apart.
The two recall sources ran the same topic veto with the same log line
spelled two ways. It is one helper now.
Timeouts and read-back budgets that were literals get names beside the
source that spends them. The money and list comments in querySources sat
above the wrong entries, so the ordering argument read backwards.
No source moved, no gate changed.
V-576. The diagnosis in the task was wrong and the agent said so. The stored
body was never generated: actions_note.go writes dec.Utterance and always has.
The invention was one layer up, in the two places the owner hears. llmrouter.go
set a note's Slots.Text to firstNonEmpty(a.Text, utterance), where a.Text is the
router model's own free-text field. replier.go renders that slot into the
confirmation. So a fragment with no content let the router write anything into
the payload slot and then had it read back. Two runs, two different inventions,
which is what was measured.
A note's Slots.Text is now the utterance, unconditionally.
A correction fragment also writes nothing at all. correctionFragment uses no new
Russian stem patterns: the first token is a one-word refusal from the confirm_no
lexicon, the sentence negates and then contrasts, and no token is a verb form
per morph.IsVerbForm. The verb test is what spares a real note, so "нет, я не
поеду, а останусь" is still stored.
The repair path is left to V-573. This fragment carries no intent word, so
parseRepair correctly declines it, and widening repair to claim fragments it
cannot redo would be the wrong fix.
V-575. Nexus and Praxis were the same JSON transport written twice.
ecosystemHTTP holds the base URL, token, client, service name and version
header, and exposes setHeaders, call, getJSON and postJSON. Both clients embed
it, so Resolve, Health and the four lifecycle posts are one or two lines each.
setEcosystemHeaders became a method reading req.Context(), which is the same
value with no way to pass a context the request never carried.
Three degrade paths repeated the same rule: unauthorized means EcoDenied,
anything else means EcoDown, and name the service. That is ecosystemGap now. The
failed Nexus resolve was traced identically in two places, so nexusResolveFailed
holds it and keeps the rune-count redaction. The surface-a-spoken-item block was
written twice and is now surfaceSpoken, which calls Surface and nothing else.
Every lifecycle verb call site is untouched. Surface is still Surface. So are
the confirm and risk tier gate, scopedToEntity's refusal to relabel an unscoped
response, attentionCannotTell's hedge rules, and the demonstrative rules in
resolveSurfacedPosition.
One shape did change: a malformed Nexus base URL used to return a plain wrapped
error and now returns an ecosystemError like every other failure on that path.
No test exercises it and every caller already treats a non-nil error as a
dependency failure.
The note body was already the utterance. Two other holes were not.
The LLM router filled Slots.Text for a note from the model's own text
field, and that slot is what the replier reads out. So the confirmation
he heard named things he never said, twice over, differently each time.
The note payload is now the utterance and the model cannot touch it.
A correction with no referent is also not a note. 'нет, не маме, а папе'
names no intent, so parseRepair declines it and it routed as a fresh
note. actionNote now declines it and asks instead of filing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two sweep agents reported the same thing independently: make test fails at
fmt-check on this file, and it is unrelated to their work. It went unformatted
onto master earlier. gofmt only, no test changed.
--no-verify: the pre-commit hook refuses master, and this unattended run merges
to master by the owner's instruction.
Three call sites wrote the same two lines: a rejected credential says
eco_denied, anything else says eco_down, and both name the service.
That is ecosystemGap now, so a fourth caller cannot get it half right.
The failed Nexus resolve was also traced twice in the same shape, once
in the entity attention arm and once in the Hexis act. Both now go
through nexusResolveFailed, which keeps the rune count redaction.
Surfacing a spoken item was written twice as well. surfaceSpoken calls
Surface and nothing else, because reading an item aloud is not an
acknowledgement.
No behaviour change: same lifecycle verbs, same replies, same trace
fields. traceErrorFields lost a duplicated default and returns what it
returned before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nexus and Praxis were the same HTTP client written twice: build the
request, stamp the headers, send it, check the status, decode the body,
and wrap each failure in an ecosystemError. They differ only in the
service name and the version header, so both now embed ecosystemHTTP and
call getJSON or postJSON.
setEcosystemHeaders took a context beside the request it was stamping.
It now reads req.Context(), which is the same value, so a caller cannot
pass a context the request never carried.
No behaviour change. Same headers, same statuses, same error types. One
error changed shape: a malformed base URL used to come back from Nexus
resolve as a plain wrapped error and is now an ecosystemError like every
other failure on that path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
V-561, and the owner's acceptance transcript. A side query no longer kills the
flow it interrupted. The answer comes first and the parked question comes back
in the same reply.
напомни позвонить маме -> Когда?
какая сейчас погода в Риме? -> погода не настроена, на какое время
поставить напоминание?
в 21:00 -> хорошо, напомню сегодня в 21:00.
The owner rejected "Прошлую просьбу отпускаю." for this shape. It is kept for
new_request and cancel, where something really was dropped, and gone for
side_query.
clarifyResumedVariants is a new deck, one wording per slot, all infinitive so
there is no gender to get wrong. A resume spends no attempt, so it is not an
attempt ladder.
Resume is a deferred call in runTurn rather than a call at each exit. Eight
returns sit between the fall-through and the replier, and one that forgot would
park a request for ever.
askClarify pushes rather than puts when a side query needs clarifying of its
own. Put would replace the top, which is the flow the side query was allowed to
interrupt rather than kill.
TakeExpired returns a count, not a bool. The stack holds two and drops both when
the top times out, so the singular "прошлую просьбу" would have been a lie
about the number. clarifyExpiredPluralVariants covers it.
The contract row for a nested question is green and unskipped. The owner's own
transcript stays skipped, because StubDateTimeParser reads neither "на 9" nor
"на завтра". That is V-543 and V-562, and the skip reason now says so.
Seven copies of add one, go, defer done. The helper keeps the WaitGroup
registration next to the goroutine it counts, so a worker cannot be started
without shutdown waiting for it.
The UnlockFn goroutines keep the shape they had, wg and all.
The unlocked boot and UnlockFn each carried their own copy of the phraser
config, the delivery sinks, the dispatcher and the tick loop intervals. Two
copies of wiring drift, and a cold started daemon then behaves unlike a
normally booted one for no reason anybody wrote down.
Extracted wirePhraser, wireGatherer, wireDispatcher and wireTickLoop, and
named the phraser fallbacks and the passkey step up window.
No behaviour change. Startup order, log lines and error strings are the same.
The parseable twin of the owner's transcript goes green and loses its skip: Rome
is answered, the question survives the side query on the same attempt, and the
answer after it completes the reminder he actually asked for.
His transcript verbatim stays skipped, and V-561 was never going to unskip it.
What is left there is the parser — StubDateTimeParser reads neither "на 9" nor
"на завтра", so the third turn lands as an answer that filled nothing. The skip
reason now names V-543 and V-562 instead of this task.
Two V-560 tests asserted the drop notice and now assert the suspend: nothing
says a request was let go, the reply ends with the resumed question, and the
parked question is still there on attempt 1 with what it was about intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
V-560 classified the side query correctly and then dropped the request behind
it, saying "Прошлую просьбу отпускаю." The owner rejected that on sight: he
asked about the weather in the middle of setting a reminder, and being told the
reminder was let go reports a loss he did not ask for. It had not been lost —
there was simply nowhere to put it.
There is now. ClarifyStore grew a bounded stack in V-559 and nothing called
Push; this is the caller it was built for. A side query leaves the question
parked exactly as it is, the words are answered as themselves, and the question
comes back on the end of the same reply — one utterance, two acts.
The resumed question is not the first one again. "Когда?" works in the same
breath as "напомни позвонить маме" and does not work after a turn about Rome, so
the deck has a second form per slot that names the request: "На какое время
поставить напоминание?". No attempt is spent, because he answered the side query
and not the parked question, and charging a retry for a turn that was never an
answer is the V-554 shape.
clarifyDropped stays for new_request and cancel, where something really does
die. Two things can now die at once, so TakeExpired reports a count instead of a
bool and the expiry notice has a plural wording — "прошлую просьбу" when two
were lost would be a lie about the number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
V-567, severe. classifyConfirm was strings.Contains over bare stems, so
"погода" contained "да" and "покажи" contained "ок". resolveConfirm runs
before routing, so asking the weather while a confirm was parked executed the
destructive tool or the bound Hexis capability. Measured on the box before the
fix: "какая погода" ran the parked act.
Second defect found while fixing: an unrecognised utterance also disarmed the
confirm, because claim() cleared the pending slot before the verdict was read.
An utterance that is not an answer is not a cancellation either.
The yes and no words are now two closed sets in internal/lexicon, matched as
whole tokens, phrases longest-first so "не надо" is read before "нет", and
negatives before positives. The whole utterance must be answer words plus
filler, so "давай посмотрим погоду" is unknown and leaves the confirm parked.
"хорошо", "ладно" and "точно" are deliberately absent: they open a sentence
about something else as often as they answer one.
Conflict in lexicon_ru_v1.json resolved by hand: V-560's slot_value_frame and
dialogue_cancel and V-567's confirm_yes and confirm_no all belong. 22 sets,
JSON validated, lexicon, mavend, router and router/eval green with -race.
--no-verify: the pre-commit hook refuses master, and the owner asked for
straight-to-master merges for this unattended run.
Conflict in cmd/mavend/voice.go resolved by hand: V-564's decision record
install and V-560's memoised turn route both belong at the top of runTurn, as
steps 0 and 0b. Full -race suite green over ./internal/... ./cmd/... after the
resolution, 64 packages, no failures.
--no-verify: the pre-commit hook refuses master, and the owner asked for
straight-to-master merges for this unattended run.
classifyConfirm was a substring test over bare stems, so "погода",
"дальше", "надо" and "давление" all read as "да", and "покажи" and
"около" read as "ок". resolveConfirm runs before routing, so a question
about the weather executed a parked destructive act. Reproduced on the box:
with "restart nonexistent-xyz" parked, "какая погода" answered "не
получилось выполнить команду".
The yes and no answers are now two closed sets in internal/lexicon, matched
as whole tokens longest-first, and the WHOLE utterance must be answer words
and filler — a leading "давай" does not make "давай посмотрим погоду" an
answer. Anything else is confirmUnknown, which now leaves the confirm parked
instead of disarming it: an utterance that is not an answer is not a
cancellation either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
V-568. queryEmbed claimed the turn on an EmbedQuery error and answered
QueryFailAnswer from position 12 of querySources, above memory, notes, the
personal boundary, search, Kiwix and general knowledge. So one failing embed
call made every question below it answer "не смогла ответить", including the
ones search and Kiwix would have answered without the embedder at all.
The notes source had the same bug one position lower, on a QueryNotes error.
Both now log once and pass. Both also gained an empty-vector guard, because
scores off a nil vector are not a "there is nothing" answer.
The distinction the audit used: a source that looked and found nothing may
claim, a source that could not look must pass. day-plan, habits, feeds,
calendar, weather, home, network and web keep claiming, because each already
matched a question about his own data and nothing below can answer it.
Answering a personal question with a paragraph about the world is V-474 and
V-479.
queryEmbed claimed the turn on a failed EmbedQuery and returned
QueryFailAnswer. It sits above memory, notes, the personal boundary,
search, Kiwix, the named page and general knowledge, so one ONNX error
answered every question below it with "не получилось найти ответ",
including the ones search and Kiwix answer without an embedder at all.
It now logs and passes, the shape turnVector already had. The two recall
sources below pass on an empty vector rather than searching on one, and
queryNotes passes on a store error too: a source that could not look is
not a source that looked and found nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The measured failure of 2026-08-05 end to end through the real cascade, plus
the content test the classifier rests on, the call-off, the cost bound, and the
persona checks over the two new lines.
resolveClarifyAnswer now decides what the utterance IS before deciding what to
do with it: route, classify the role, then answer, cancel, or step aside. The
side_query and new_request arms drop the parked question and say so — nothing
may die in silence — and V-561 turns the first of those into a suspend.
isOwnRequest is deleted rather than extended. It only ran where the answer
filled nothing, which is why the greedy 'сейчас' in a weather question walked
straight past it and set a reminder for a time nobody asked for.
turnRoute memoises this turn's routing, so the resolver that reads it to
classify a role and the pipeline that acts on it cannot end up with two
different decisions, and the extra route is paid once. needsRoute is the fast
path: an utterance with no content of its own reaches the same role without the
model.
The turn role — answer, correction, side_query, new_request, cancel, plus the
not_applicable a resolver may return — decided from what the router made of the
utterance instead of from whatever the extractor found inside it.
The content gate in front of the evidence is what separates a hedged slot value
from a question: 'а что если в 11:00' leaves nothing of its own behind and
'какая сейчас погода в Риме' leaves the weather and Rome. Nothing calls it yet.
V-564. One decision.Record per turn: the utterance, the winner, and a Claim
per claimant carrying its stage, name, the intent it would have made the turn,
the score it reported, the outcome and the reason. HasScore is separate from
the score so a real 0.0 is not read as no score. Outcomes are won, declined,
lost_on_order, lost_on_score, thinned, merged, never_asked.
Every stage declares its roster up front, so Finish names everyone who never
reported. NEVER ASKED is explicit rather than an absence, which is the fact
the hardcoded ordering hides.
Covered: the seven pre-route resolvers, eleven stage 0 grammar sets, the LLM
router and the classifier with which arm of gateLLMDecision thinned a route,
the classifier runners-up, the follow-up merge, 27 query sources, and a
terminal action-handler or clarify-ask claim.
On by default, no flag. It rides the context like querysource.go and is
installed in runTurn, so mic, telegram and web leave the same trail. Storage
is a 25-turn in-memory ring: no write on the answer path, no migration, and
none of his words outlive the diagnosis. Readable on /trace.
TestRecordingDoesNotChangeTheReply answers the same utterances with and
without the ring.
Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store
adapter refuses rather than pretending a table exists. No voice wiring means an
empty list and not an error, because a box with no voice path has had no turns
to arbitrate.
The two claimant sets that live in the daemon are where the arbitration is
least visible: both are a hardcoded order of functions that each answer 'is
this mine?' alone. The ladder declares its roster up front, so a rung that
never ran is named rather than omitted, and the query chain does the same for
the sources below the one that claimed.
Recording is installed in runTurn and not in the IPC entry point, so the mic,
telegram and the web leave the same trail. A record only the web produced would
be missing exactly the turns that are hardest to reproduce.
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.
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.
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.
"расскажи про Байкал" 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.
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.
"что ты записала сегодня?" 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
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
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.
Step 5 of the board build. internal/tasks/stall.go counts three shapes —
overdue, sitting longer than StallDays, waiting for confirmation — and states
nothing about what any of them means. That is the line
internal/memory/behavior.go already drew for habits, and the reason is the
same: a 1.7B asked to judge will agree fluently and launder a guess into a
decision. A test asserts the wording carries no assessment.
Sitting is measured from created_ts, the only clock a live row carries: the
store stamps resolved_ts and nothing else. So "no state change in eleven days"
is exactly "captured eleven days ago and still live", which is narrower than
the plan's wording and is the claim the data supports. A candidate is never
counted as overdue, because its due date is Maven's reading of a mail rather
than a deadline he set.
Not a nag. No tick rule reads the counts; they go on /tasks and into the list
reply when he asks, and tickLoop.dayPlan still does not read tasks at all. The
empty case renders as nothing: "ничего не залежалось" appended to every list
read is a nag with a friendly face.
Three say entries, so the page and the spoken list cannot word it differently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
The other half of the stage-0 rule. actionAct intercepts task_status ahead of
both ecosystem clients, because the board is Maven's own store and reaching a
capability registry would answer a question about his task list with a gap.
Three answers besides the move, and none of them guesses. No match says so.
More than one match asks which, since closing the wrong task marks work he
never finished as done. No task named asks which too, because the router claims
the turn without the referent and the list lives here.
Matching is normalised containment either direction, over the same
store.NormalizeTaskText key capture dedupes on — he shortens what he said as
often as he pads it. Deliberately not fuzzy: a ranked best guess always returns
exactly one answer, and the one thing this has to be able to say is that it is
not sure.
A candidate he says is done takes both legal store moves. The store refuses
candidate → done, and saying it out loud IS the confirmation the candidate was
waiting for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
Step 4 of the board build (docs/plans/15-board-surface.md). Naming a task
instead of its position reached nothing: "закрой задачу купить молоко" routed
act, found no allowlisted fn, and the gate asked "Что сделать?". The position
path already worked through resolveCandidate, but only in the two turns after
she read the list out.
TaskStatusGrammar is the same shape TaskCaptureGrammar uses — matches broadly,
decides in Build, no eighth intent — and fills the fn slot with task_status,
which is neither a Hexis capability nor a Praxis one. Three conditions, all
required: the board noun, so no ordinary sentence claims a turn; exactly one
status class, since "готово, убери" names two and asking beats picking; and a
status word matched as an imperative exactly or a stative by lemma. So a bare
"готово" and a bare "закрой" are not this rule's, and the second belongs to
Praxis, which claims it already.
Two lexicon sets rather than one with a value. The store records which of the
two transitions happened and /tasks shows it: work he chose to stop is not work
he did.
Measured on the fixture, two new cases (ru-act-020, ru-act-021). Classifier +
ONNX 62/89 (69.7%) → 64/91 (70.3%); cascade+llm 67/89 (75.3%) → 69/91 (75.8%,
80.2% intent-only) at p50 1.225s. Both new cases claimed at stage 0, no case
regressed, clarify counts unchanged at 3 false / 1 missed.
The task's own warning stands: every such grammar runs its parser ahead of the
resident model on every turn, so this is the last one that is free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
Item 4. actionChat calls h.phraser.PhraseChat, and LLMPhraser posts raw
HTTP to /v1/chat/completions rather than going through the llm client
scriptedLLM stands in for. The simulator wired phraser.NewStub() anyway,
so no scenario could assert what she says on a chat turn: every reply came
back as a pick from fallbacks_ru_v1.json, four variants deep, and the same
scenario returned "тут я пас." one run and "не знаю, честно." the next.
scriptedPhraser embeds the Stub and overrides PhraseChat only, reading the
same script entries the router reads. A reply is accepted in either shape
the phrasing contract allows, the {"response","mood"} object or plain text,
so a scenario writes one thing for both paths.
An unscripted chat turn returns an error rather than a fallback, matching
scriptedLLM: actionChat logs it and uses ChatFallback(), so scenarios that
never meant to assert a chat reply behave as before.
conversation_anaphora turn 4 now pins its text — the reply that asks which
device he means, which is the recorded defect in the box's own words.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five Russian turns, one monitor, four questions that say "он" and never
name it again. Item 3 of the task: the shape had nowhere to fail, because
the routing fixture scores one utterance at a time and a conversation that
breaks on turn 2 cannot lose a point there.
Routes are scripted exactly as the box produced them on 05-08-2026. Turn 1
files a fact despite "давай поболтаем", the questions go to query, turn 4
goes to chat, and none of the five replies names the monitor. Four steps
assert the reply LACKS "монитор" and are marked WRONG in their notes with
what each must become.
The absence assertion is forced, not chosen. The simulator wires
phraser.NewStub(), and PhraseChat posts raw HTTP to /v1/chat/completions
rather than through the llm client the harness scripts, so a chat reply
cannot be scripted at all. The wrong replies come from
fallbacks_ru_v1.json, which picks between four variants per turn, so
asserting a string would pin the picker. Missing referent holds whichever
variant she reaches for.
Items 1 and 2 stay open: they are owner decisions about which store a
referent comes from and whether "давай поболтаем" claims a turn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>