mavend: wire resolveAction into actionAct

Daemon half of the action-resolution boundary:

- Add resolveAction wrapper: delegates to ResolveActionCandidate,
  records outcome in the decision trace (action-resolve:route/matcher)
- Refactor actionAct: remove matcher call, consume candidate, write
  resolved values back into Slots for downstream branches
- Add 8 integration tests pinning all required scenarios:
  route-sourced, matcher-sourced, matcher miss, destructive confirm,
  task-status intercept, stage-0, learned-router, alias match

All existing tests pass. Execution/risk/confirmation unchanged.
This commit is contained in:
2026-09-05 21:50:33 +04:00
parent 064747f192
commit 025f81e961
3 changed files with 204 additions and 10 deletions
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"context"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/router"
)
// resolveAction produces an ActionCandidate from a routing decision. It is the
// single boundary between routing and action execution: everything downstream
// (refusesCommand, task-status, Praxis, Hexis, proposeGap, tool.Executor.Exec)
// consumes the candidate rather than re-resolving the function.
//
// Delegates to router.ResolveActionCandidate for the resolution logic, then
// records the outcome in the decision trace.
func (h *reactiveHandler) resolveAction(ctx context.Context, dec router.Decision) router.ActionCandidate {
candidate := router.ResolveActionCandidate(dec, h.matcher)
// Record the resolution outcome in the decision trace.
if dec.Intent == router.IntentAct {
if candidate.ActionResolved() {
noteActionResolution(ctx, string(candidate.Source), candidate.Fn, true)
} else {
noteActionResolution(ctx, "matcher", "", false)
}
}
return candidate
}
// noteActionResolution records the action resolution outcome in the decision
// trace. A nil recorder is the normal case in tests.
func noteActionResolution(ctx context.Context, source, fn string, resolved bool) {
rec := decision.From(ctx)
if rec == nil {
return
}
outcome := decision.Declined
reason := "no match"
if resolved {
outcome = decision.Won
reason = "resolved via " + source
if fn != "" {
reason += ": " + fn
}
}
rec.Note(decision.Claim{
Stage: decision.StageAction,
Claimant: "action-resolve",
Outcome: outcome,
Reason: reason,
})
}
+21 -10
View File
@@ -11,9 +11,14 @@ import (
"github.com/kami/maven/internal/tool"
)
// actionAct handles router.IntentAct: match a verb to an enabled tool, offer
// it to the ecosystems first, and run it behind the confirm gate and the
// allowlist. proposeGap and the confirm gate itself live in confirm.go.
// actionAct handles router.IntentAct: resolve the action, offer it to the
// ecosystems first, and run it behind the confirm gate and the allowlist.
// proposeGap and the confirm gate itself live in confirm.go.
//
// Action resolution happens in resolveAction (actionresolve.go) — a single
// boundary that produces an ActionCandidate before execution. This function
// consumes the candidate; it no longer decides which function/tool the user
// meant.
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
// An allowlist or a model route is evidence about WHAT could run, never
// authority to run it. Keep the user's negative command at the execution
@@ -23,13 +28,19 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
return commandProhibitionReply
}
// tool executor: run the matched fn against the enabled allowlist.
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
// didn't go through the stage-0 act grammar).
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
}
// Resolve the action: produce an ActionCandidate from the routing
// decision. The candidate carries the resolved function, its arguments,
// and where the resolution came from (route or matcher).
candidate := h.resolveAction(ctx, dec)
// Write the candidate's resolved values back into Slots so the existing
// branches (task-status, Praxis, Hexis, proposeGap, tool.Executor) work
// unchanged. This is the mechanical adjustment that preserves all existing
// behavior without redesigning those branches.
if candidate.ActionResolved() {
dec.Slots.Fn = candidate.Fn
dec.Slots.Args = candidate.Args
dec.Slots.HasFn = true
}
// The board is Maven's own store, so a spoken status change is answered here