diff --git a/CLAUDE.md b/CLAUDE.md
index 72cc9b2..6c60d1d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -261,6 +261,22 @@ of the house. A demonstrative ("отметь это как сделанное")
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
the cascade rather than transitioning the wrong item.
+**Who claimed a turn is now recorded, and so is who did not** (V-564, umbrella
+V-558). Arbitration between the claimants on the utterance stream is order,
+hardcoded in the pre-route resolver ladder, in `buildRouter` and in
+`querySources`. `internal/decision` records one `Record` per turn: every
+claimant, what it would have made the turn, the score it reported, and whether
+it won, declined, lost on score, was thinned by a gate or was **never asked**.
+The record rides the context, the same seam `querysource.go` uses, so a claim
+site cannot change a route and a context with no record costs nothing. It is
+installed in `runTurn`, so the mic, telegram and the web all leave the same
+trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
+read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
+Nothing persists: a turn record is read minutes later or never, and his words do
+not belong in a table that outlives the diagnosis. Adding a rung to the ladder
+in `runTurn` means adding its name to `preRouteLadder` in
+`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
+
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go
index e24651a..80dec77 100644
--- a/cmd/mavend/actions_query.go
+++ b/cmd/mavend/actions_query.go
@@ -11,6 +11,7 @@ import (
"unicode"
"github.com/kami/maven/internal/crawl"
+ "github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/morning"
@@ -158,8 +159,17 @@ var querySources = []querySource{
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
t := &queryTurn{dec: dec}
+ // The roster, so the record can say which sources were never reached rather
+ // than leaving them out and letting a reader assume they looked and passed
+ // (V-564). Finish names everyone below the winner.
+ decision.Expect(ctx, decision.StageQuery, querySourceNames())
+ rec := decision.From(ctx)
for _, src := range querySources {
if dec.Continued && !src.dateAware {
+ rec.Note(decision.Claim{
+ Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
+ Reason: "a continuation turn only asks the date-aware sources",
+ })
continue
}
if reply, ok := src.answer(h, ctx, t); ok {
@@ -172,8 +182,16 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
// caller asked for one, so /chat can show it (V-539).
log.Printf("voice: query claimed by source %q", src.name)
noteQuerySource(ctx, src.name)
+ rec.Note(decision.Claim{
+ Stage: decision.StageQuery, Claimant: src.name,
+ Intent: string(dec.Intent), Outcome: decision.Won,
+ })
return reply
}
+ rec.Note(decision.Claim{
+ Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined,
+ Reason: "it had no answer for this turn",
+ })
}
if dec.Continued {
// The previous question cannot be re-asked for another day. Saying so
diff --git a/cmd/mavend/decisiontrace.go b/cmd/mavend/decisiontrace.go
new file mode 100644
index 0000000..247992c
--- /dev/null
+++ b/cmd/mavend/decisiontrace.go
@@ -0,0 +1,132 @@
+// mavend/decisiontrace.go — the daemon's half of the per-turn decision record.
+//
+// V-564. The router says what the cascade did (internal/router/decisiontrace.go);
+// this file covers the two claimant sets that live in the daemon: the stateful
+// resolvers that run BEFORE routing and pre-empt it unconditionally, and the
+// query source chain that runs after. Those two are where the arbitration is
+// least visible, because both are a hardcoded order of functions that each
+// answer "is this mine?" alone and none of which answers "is this more mine
+// than yours?" (V-558).
+//
+// Recording changes no route. Every helper here is a no-op on a context with no
+// record, which is what every test that does not ask for one gets.
+package main
+
+import (
+ "context"
+
+ "github.com/kami/maven/internal/decision"
+ "github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/router"
+)
+
+// preRouteLadder — the resolvers runTurn offers the utterance to before the
+// router sees it, in the order they get their say. Kept here as a roster rather
+// than derived from the code, so a resolver that returns early and skips the
+// rest still leaves the rest NAMED in the record: a claimant that never looked
+// and one that looked and passed are the distinction the ordering hides, and
+// they are the difference between a bug in the ladder and a bug in a resolver.
+//
+// Adding a step to runTurn means adding its name here. Nothing enforces that,
+// and nothing should: a missing name costs one line of the record, while a
+// check that walks the ladder would have to run the ladder.
+var preRouteLadder = []string{
+ "confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
+}
+
+// notePreRoute records one rung of that ladder and passes its verdict through
+// unchanged, so the call site stays the single `if handled` it already was.
+func notePreRoute(ctx context.Context, name string, handled bool) bool {
+ rec := decision.From(ctx)
+ if rec == nil {
+ return handled
+ }
+ if handled {
+ rec.Note(decision.Claim{
+ Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won,
+ Reason: "it pre-empted routing, so the router never saw this turn",
+ })
+ return handled
+ }
+ rec.Note(decision.Claim{
+ Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined,
+ Reason: "nothing of its own was pending",
+ })
+ return handled
+}
+
+// noteTerminal records whoever actually produced the reply, but only if the
+// turn is still unclaimed. A route decides the intent; it does not answer, and
+// on a thinned route or a plain act nothing downstream keeps a scoreboard. So
+// the record would otherwise close with an empty winner, which reads as a lost
+// turn instead of an asked question.
+func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) {
+ decision.From(ctx).NoteIfUnclaimed(decision.Claim{
+ Stage: decision.StageAction, Claimant: claimant,
+ Intent: string(intent), Reason: reason,
+ })
+}
+
+// noteMerge records the follow-up merge, which is the one claimant that edits
+// the winning decision instead of taking the turn from it. It is compared on
+// the four slots the merge can fill, because a Decision holds a slice and is
+// not comparable.
+func noteMerge(ctx context.Context, before, after router.Decision) {
+ rec := decision.From(ctx)
+ if rec == nil {
+ return
+ }
+ changed := before.Slots.HasTime != after.Slots.HasTime ||
+ before.Slots.HasKey != after.Slots.HasKey ||
+ before.Slots.HasFn != after.Slots.HasFn ||
+ before.Slots.Text != after.Slots.Text ||
+ before.Intent != after.Intent
+ if !changed {
+ rec.Note(decision.Claim{
+ Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined,
+ Reason: "no slot of this turn was left for a previous one to fill",
+ })
+ return
+ }
+ rec.Note(decision.Claim{
+ Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent),
+ Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn",
+ })
+}
+
+// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired.
+// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only
+// converts it.
+func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision {
+ if w == nil || w.handler == nil || w.handler.decisions == nil {
+ return nil
+ }
+ ring := w.handler.decisions
+ return func(n int) []ipc.TurnDecision {
+ recs := ring.Recent(n)
+ out := make([]ipc.TurnDecision, 0, len(recs))
+ for _, rec := range recs {
+ claims := make([]ipc.TurnClaim, 0, len(rec.Claims))
+ for _, c := range rec.Claims {
+ claims = append(claims, ipc.TurnClaim{
+ Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent,
+ Score: c.Score, HasScore: c.HasScore,
+ Outcome: c.Outcome, Reason: c.Reason,
+ })
+ }
+ out = append(out, ipc.TurnDecision{
+ Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims,
+ })
+ }
+ return out
+ }
+}
+
+// querySourceNames — the query chain's roster, in chain order.
+func querySourceNames() []string {
+ names := make([]string, len(querySources))
+ for i, src := range querySources {
+ names[i] = src.name
+ }
+ return names
+}
diff --git a/cmd/mavend/decisiontrace_test.go b/cmd/mavend/decisiontrace_test.go
new file mode 100644
index 0000000..813d34f
--- /dev/null
+++ b/cmd/mavend/decisiontrace_test.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kami/maven/internal/decision"
+ "github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/memory"
+ "github.com/kami/maven/internal/router"
+ "github.com/kami/maven/internal/tool"
+ "github.com/kami/maven/internal/voice"
+)
+
+// traceHandler — a handler with the decision ring wired, the same shape the
+// daemon builds in wireVoice.
+func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
+ t.Helper()
+ st := newTestStore(t)
+ api := ipc.NewStoreAPI(st)
+ now := time.Now()
+ emb := router.NewHashEmbedder(1024)
+ return &reactiveHandler{
+ api: api,
+ recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
+ router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
+ replier: voice.NewStubReplier(),
+ now: func() time.Time { return now },
+ dataStore: st,
+ decisions: ring,
+ }
+}
+
+// findClaim — the first claim for a claimant, or nil.
+func findClaim(rec *decision.Record, claimant string) *decision.Claim {
+ for i := range rec.Claims {
+ if rec.Claims[i].Claimant == claimant {
+ return &rec.Claims[i]
+ }
+ }
+ return nil
+}
+
+// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0
+// grammar claims must leave a record naming that grammar as the winner, naming
+// a pre-route resolver that declined, and naming the routing engines that were
+// never reached at all. The last of those is the fact the hardcoded ordering
+// hides: "classifier" absent from the record and "classifier" never asked read
+// the same to a human, and only one of them is the truth.
+func TestTurnRecordNamesWinnerAndLosers(t *testing.T) {
+ ring := decision.NewRing()
+ h := traceHandler(t, ring)
+
+ reply := h.handleText(context.Background(), "web", "сколько сейчас времени")
+ if reply == "" {
+ t.Fatal("turn produced no reply")
+ }
+
+ recs := ring.Recent(5)
+ if len(recs) != 1 {
+ t.Fatalf("want 1 record, got %d", len(recs))
+ }
+ rec := recs[0]
+ if rec.Utterance != "сколько сейчас времени" {
+ t.Errorf("utterance = %q", rec.Utterance)
+ }
+ if !strings.HasPrefix(rec.Winner, "stage0:") {
+ t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner)
+ }
+
+ // A loser that examined the turn: the confirm resolver ran first and had
+ // nothing pending.
+ confirm := findClaim(rec, "confirm")
+ if confirm == nil || confirm.Outcome != decision.Declined {
+ t.Errorf("confirm claim = %+v, want a decline", confirm)
+ }
+
+ // A loser that never looked: stage 0 answered, so neither routing engine
+ // was reached.
+ for _, name := range []string{"llm-router", "classifier"} {
+ c := findClaim(rec, name)
+ if c != nil && c.Outcome == decision.Won {
+ t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c)
+ }
+ }
+
+ // And every rung of the ladder below the winner is named, not omitted.
+ for _, name := range preRouteLadder {
+ if findClaim(rec, name) == nil {
+ t.Errorf("ladder rung %q is missing from the record", name)
+ }
+ }
+}
+
+// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring
+// wired and the same turn without it must answer identically. If this ever
+// fails, a claim site is doing more than noting.
+func TestRecordingDoesNotChangeTheReply(t *testing.T) {
+ for _, utt := range []string{
+ "сколько сейчас времени",
+ "запиши что я пил воду",
+ "что у меня сегодня",
+ } {
+ withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt)
+ without := traceHandler(t, nil).handleText(context.Background(), "web", utt)
+ if withRing != without {
+ t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without)
+ }
+ }
+}
+
+// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is
+// never consulted, and the record must say so rather than leave it out. This is
+// the arm that would have explained the Rome misroute in one read.
+func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) {
+ ring := decision.NewRing()
+ h := traceHandler(t, ring)
+ h.handleText(context.Background(), "web", "что у меня сегодня")
+
+ rec := ring.Recent(1)[0]
+ var asked, never int
+ for _, c := range rec.Claims {
+ if c.Stage != decision.StageQuery {
+ continue
+ }
+ if c.Outcome == decision.NeverAsked {
+ never++
+ } else {
+ asked++
+ }
+ }
+ if asked == 0 {
+ t.Fatal("no query source reported at all")
+ }
+ if never == 0 {
+ t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end")
+ }
+ if got := len(querySourceNames()); asked+never != got {
+ t.Errorf("record covers %d of %d query sources", asked+never, got)
+ }
+}
+
+// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was
+// never wired, because a box with no turns is an empty page and not an error.
+func TestTurnDecisionsFnConvertsTheRing(t *testing.T) {
+ if fn := turnDecisionsFn(nil); fn != nil {
+ t.Error("no wiring should mean no reader")
+ }
+ ring := decision.NewRing()
+ h := traceHandler(t, ring)
+ h.handleText(context.Background(), "web", "сколько сейчас времени")
+
+ fn := turnDecisionsFn(&voiceWiring{handler: h})
+ if fn == nil {
+ t.Fatal("wired handler produced no reader")
+ }
+ out := fn(10)
+ if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 {
+ t.Fatalf("conversion lost the record: %+v", out)
+ }
+}
diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go
index a498b8f..7c5d805 100644
--- a/cmd/mavend/main.go
+++ b/cmd/mavend/main.go
@@ -350,6 +350,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
+ getDecisions: turnDecisionsFn(voiceW),
seedStore: seedStoreIfAllowed(st),
nexus: nexusOf(voiceW),
}
@@ -619,6 +620,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
+ getDecisions: turnDecisionsFn(voiceW),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
diff --git a/cmd/mavend/tick_api.go b/cmd/mavend/tick_api.go
index 24ef255..236b521 100644
--- a/cmd/mavend/tick_api.go
+++ b/cmd/mavend/tick_api.go
@@ -23,6 +23,7 @@ type daemonAPI struct {
chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
+ getDecisions func(n int) []ipc.TurnDecision
// nexus — the identity client, nil when no nexus block is configured. It
// is what makes ResolveEntity answerable at all; without it the store
// adapter's refusal stands, and a surface that wanted an entity id says so
@@ -118,6 +119,17 @@ func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
return toIPCTickTrace(*trace), nil
}
+// TurnDecisions — the arbitration records of the last few turns (V-564). Nil
+// getter means voice was never wired, and that is an empty list rather than an
+// error: a box with no voice path has had no turns to arbitrate, which is not a
+// fault and renders as an empty table.
+func (d *daemonAPI) TurnDecisions(ctx context.Context, n int) ([]ipc.TurnDecision, error) {
+ if d.getDecisions == nil {
+ return nil, nil
+ }
+ return d.getDecisions(n), nil
+}
+
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go
index 2a949bc..0e052c6 100644
--- a/cmd/mavend/voice.go
+++ b/cmd/mavend/voice.go
@@ -53,6 +53,7 @@ import (
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/crawl"
+ "github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/lexicon"
@@ -137,6 +138,13 @@ type reactiveHandler struct {
// slot per reach, not one for the box. nil ⇒ no carry-over.
dialogueSessions *dialogue.SessionStore
+ // decisions holds the last few turns' arbitration records (V-564): who
+ // claimed the turn, who lost it and who was never asked. In memory and
+ // bounded, because a turn record is read minutes later or never, and none
+ // of his words belong in a table that outlives the diagnosis. nil ⇒ nothing
+ // is recorded, which is what a test that did not ask for one gets.
+ decisions *decision.Ring
+
// clarifyStore parks the request behind an open question she asked (see
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
clarifyStore *dialogue.ClarifyStore
@@ -248,6 +256,18 @@ const (
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
+ // 0. the decision record (V-564). Installed here rather than in the IPC
+ // entry point, so the mic, telegram and the web all leave the same trail —
+ // a record only the web produced would be missing exactly the turns that
+ // are hardest to reproduce. It rides the context, costs a few dozen structs
+ // on a human-rate path, and no claim site can change a route with it.
+ if h.decisions != nil {
+ var rec *decision.Record
+ ctx, rec = decision.With(ctx, text)
+ decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
+ defer func() { h.decisions.Push(rec.Finish(h.now())) }()
+ }
+
// 1. expired clarify — a question was parked but its TTL ran out, so the
// request behind it is gone. Say that out loud (see clarify.go) and carry
// on: these words are still routed as a fresh utterance below, with the
@@ -263,7 +283,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// 2. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
// get classified as some other intent.
- if reply, handled := h.resolveConfirm(ctx, text); handled {
+ if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) {
return withNotice(expiredNotice, reply)
}
@@ -275,7 +295,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// so the notice is empty here in practice. withNotice anyway: every exit
// from runTurn carries it, and that is what stops the next one from
// forgetting.
- if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
+ if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) {
return withNotice(expiredNotice, reply)
}
@@ -283,7 +303,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// "тихий режим" / "quiet on" would route through the classifier
// unreliably (it's a command, not a free-form query), so we match it
// before routing. Same pattern as the confirm turn above.
- if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
+ if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) {
return withNotice(expiredNotice, reply)
}
@@ -291,14 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// sent. Only handled when a pending nudge is actually inside the window
// (snooze.go); otherwise the words route normally, because "потом" is an
// ordinary word and eating every one of them would break real sentences.
- if reply, handled := h.resolveSnooze(ctx, text, src); handled {
+ if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) {
return withNotice(expiredNotice, reply)
}
// 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the
// contentless form is intercepted here; "выпил воды" keeps routing and
// closes the nudge after its fact lands (ackFromFact, step 8b).
- if reply, handled := h.resolveAck(ctx, text, src); handled {
+ if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) {
return withNotice(expiredNotice, reply)
}
@@ -306,7 +326,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// turn and names what it should have been (repair.go). Before routing,
// like the confirm and clarify turns: routing the correction as a fresh
// utterance files the correction itself instead of fixing anything.
- if reply, handled := h.resolveRepair(ctx, text); handled {
+ if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) {
return withNotice(expiredNotice, reply)
}
@@ -314,7 +334,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word
// and keeps routing.
- if reply, handled := h.resolveCandidate(ctx, text, src); handled {
+ if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) {
return withNotice(expiredNotice, reply)
}
@@ -359,7 +379,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// ("а завтра?" … "а послезавтра?") keeps working.
if h.dialogueSessions != nil {
if !cont {
- dec = followUpMerge(prev, dec, now)
+ merged := followUpMerge(prev, dec, now)
+ noteMerge(ctx, dec, merged)
+ dec = merged
}
if !dec.Clarify {
h.rememberTurn(ctx, prev, dec, now)
@@ -380,6 +402,8 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
return withNotice(expiredNotice, reply)
}
if question, asked := h.askClarify(ctx, dec); asked {
+ noteTerminal(ctx, "clarify-ask", dec.Intent,
+ "the route was below the threshold, so she asked instead of acting")
return withNotice(expiredNotice, question)
}
}
@@ -396,6 +420,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// the round-trip stays alive.
replyText := h.applyAction(ctx, dec)
log.Printf("voice: applyAction returned: %q", replyText)
+ // A query turn was already claimed by a source inside the chain; every other
+ // intent has no chain and no scoreboard, so the handler is the winner.
+ noteTerminal(ctx, "action-handler", dec.Intent, "")
// 8b. a fact that answers a live nudge closes it as `acted` (ack.go).
// Silent: the fact reply stands, she does not congratulate him for it.
diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go
index dddef03..e005bed 100644
--- a/cmd/mavend/voicewire.go
+++ b/cmd/mavend/voicewire.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/kami/maven/internal/config"
+ "github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/delivery/voicesink"
"github.com/kami/maven/internal/dialogue"
@@ -293,7 +294,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
},
dataStore: dataStore,
dialogueSessions: dialogueSessions,
- clarifyStore: clarifyStore,
+ // Always on (V-564). The record is the instrument the rest of V-558 is
+ // measured with, and one that only runs when a flag is set is not there
+ // on the night the misroute happens.
+ decisions: decision.NewRing(),
+ clarifyStore: clarifyStore,
// 0 here (unset config) ⇒ the dialogue default.
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go
index 53e6b96..437ef23 100644
--- a/cmd/mavweb/handlers_test.go
+++ b/cmd/mavweb/handlers_test.go
@@ -65,6 +65,7 @@ type fakeCore struct {
// for handleTrace tests
tickTrace ipc.TickTrace
traceErr error
+ turns []ipc.TurnDecision
// for handleChatAPI tests
chatText string
@@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) {
return f.revertNewID, nil
}
+func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) {
+ return f.turns, nil
+}
+
func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
if f.traceErr != nil {
return ipc.TickTrace{}, f.traceErr
@@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) {
t.Error("rendered 'nothing fired' but a winner was set")
}
})
+
+ // The turn arbitration shares this page (V-564). A reader must see the
+ // winner, a loser and the claimants that were never asked, because the last
+ // of those is what the hardcoded ordering hides.
+ t.Run("renders the turn decision record", func(t *testing.T) {
+ core := &fakeCore{turns: []ipc.TurnDecision{{
+ Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
+ Utterance: "какая погода в риме",
+ Winner: "query:weather",
+ Claims: []ipc.TurnClaim{
+ {Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"},
+ {Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"},
+ {Stage: "query", Claimant: "kiwix", Outcome: "never_asked"},
+ },
+ }}}
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
+ body := rr.Body.String()
+ for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("rendered page is missing %q", want)
+ }
+ }
+ })
+
+ t.Run("no turns renders the empty note, not an error", func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{})
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "no turn has run") {
+ t.Error("empty ring did not render its note")
+ }
+ })
}
// --- handleRevert ---
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index 88b4414..0d89d76 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -1415,12 +1415,29 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
+ // The turn records share this page rather than getting one of their own
+ // (V-564): both answer the same question — who won, who lost and why — and
+ // one is about nudges while the other is about utterances. A read failure
+ // here is not fatal to the page: the rule trace above it still renders, and
+ // a daemon too old to know the method is the ordinary case during a rolling
+ // deploy.
+ turns, err := core.TurnDecisions(ctx, 25)
+ if err != nil {
+ log.Printf("trace: turn decisions: %v", err)
+ }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := traceTmpl.Execute(w, trace); err != nil {
+ if err := traceTmpl.Execute(w, traceData{Tick: trace, Turns: turns}); err != nil {
log.Printf("trace render: %v", err)
}
}
+// traceData — what trace.html renders: the last tick's rule arbitration and the
+// last turns' claim arbitration.
+type traceData struct {
+ Tick ipc.TickTrace
+ Turns []ipc.TurnDecision
+}
+
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable)
diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html
index 3d95037..4c997b2 100644
--- a/cmd/mavweb/trace.html
+++ b/cmd/mavweb/trace.html
@@ -1,9 +1,9 @@
{{template "shellTop" "trace"}}
Rule Trace
-{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+{{.Tick.Now | ago}} — winner: {{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}
+
+Turn Decisions
+Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.
+{{if not .Turns}}no turn has run since the daemon started
{{end}}
+{{range .Turns}}
+
+{{.Utterance}} — {{if .Winner}}{{.Winner}}{{else}}nobody{{end}} {{.Ts | ago}}
+
+
+{{end}}
{{template "shellBottom"}}