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/internal/ipc/api.go b/internal/ipc/api.go index 7025895..8128700 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -836,6 +836,30 @@ type TickTrace struct { Rules []RuleTrace `json:"rules"` } +// --- Turn decision trace DTOs (V-564) --- + +// TurnClaim — one claimant's say on one turn: who, at which stage, what it +// would have made the turn, the score it reported if it has one, and what +// happened to the claim. Same shape as RuleTrace above and for the same reason: +// a winner alone does not explain an arbitration, the losers do. +type TurnClaim struct { + Stage string `json:"stage"` + Claimant string `json:"claimant"` + Intent string `json:"intent,omitempty"` + Score float64 `json:"score,omitempty"` + HasScore bool `json:"has_score,omitempty"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` +} + +// TurnDecision — one turn's arbitration, newest first when read as a list. +type TurnDecision struct { + Ts time.Time `json:"ts"` + Utterance string `json:"utterance"` + Winner string `json:"winner"` + Claims []TurnClaim `json:"claims"` +} + // MorningRoutineItem — one checklist entry's current state. type MorningRoutineItem struct { Key string `json:"key"` diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 063ca68..ccba692 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -672,6 +672,14 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { return t, nil } +func (c *Client) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { + var d []TurnDecision + if err := c.call(ctx, MethodTurnDecisions, nReq{N: n}, &d); err != nil { + return nil, err + } + return d, nil +} + func (c *Client) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) { var e []IntakeEvent if err := c.call(ctx, MethodRecentEvents, nReq{N: n}, &e); err != nil { diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index 89464f3..d3d952d 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -169,6 +169,13 @@ type SystemAPI interface { // persisted — it's a daemon-level cache). TickTrace(ctx context.Context) (TickTrace, error) + // TurnDecisions returns the newest turn arbitration records, newest first + // (V-564). Same shape as TickTrace and RecentEvents: a bounded in-memory + // ring on the daemon, so the store adapter returns an error rather than + // pretending a table exists. Empty is a normal answer — it means no turn + // has run since the daemon started. + TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) + // RecentEcosystemTraces reads the ecosystem call log, which lives in its // own table so machine-rate traces never crowd out human-rate facts. RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) diff --git a/internal/ipc/server.go b/internal/ipc/server.go index e11cbb0..b421aed 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -583,6 +583,13 @@ var methodTable = map[Method]handlerFunc{ MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { return api.TickTrace(ctx) }), + MethodTurnDecisions: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]TurnDecision, error) { + d, err := api.TurnDecisions(ctx, p.N) + if d == nil { + d = []TurnDecision{} + } + return d, err + }), // MorningStatus intentionally has no nil→[]T{} normalization here — the // pre-table arm marshaled api.MorningStatus's result as-is (a nil slice // serializes as JSON null), and this preserves that exact wire shape. diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index 67e891b..6158d36 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -265,6 +265,12 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) { return TickTrace{}, errors.New("store: tick trace not available via direct store API") } +// TurnDecisions — same story as TickTrace: the arbitration record is a daemon +// ring, not a table, so there is nothing here to read it from (V-564). +func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { + return nil, errors.New("store: turn decisions not available via direct store API") +} + // SeedEvent — same shape as MorningStatus: writing the fact is a store call, // but extraction and detect-and-propose live in mavend, and a seed that wrote // the fact without running them would be the one thing this seam must not be, diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 25d8a6a..69681cb 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -144,6 +144,9 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64, func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) { return TickTrace{}, ErrNotImplemented } +func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { + return nil, ErrNotImplemented +} func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { return nil, ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 137828d..f671316 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -48,6 +48,7 @@ const ( MethodAcceptProposedRoutine Method = "accept_proposed_routine" MethodRevertFact Method = "revert_fact" MethodTickTrace Method = "tick_trace" + MethodTurnDecisions Method = "turn_decisions" MethodMorningStatus Method = "morning_status" MethodMCPServers Method = "mcp_servers" MethodDayPlan Method = "day_plan"