diff --git a/cmd/mavend/personaguard.go b/cmd/mavend/personaguard.go
new file mode 100644
index 0000000..eaa5d52
--- /dev/null
+++ b/cmd/mavend/personaguard.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+ "context"
+ "log"
+ "regexp"
+ "strings"
+ "sync"
+
+ "github.com/kami/maven/internal/delivery"
+ "github.com/kami/maven/internal/loop"
+ "github.com/kami/maven/internal/phraser"
+ "github.com/kami/maven/internal/phraser/eval"
+)
+
+// The persona checks, run before she speaks (Vikunja #399).
+//
+// RunChecks and RunTalkChecks only ever ran from the eval package, so
+// everything the fixtures measured was offline knowledge: we could say "about
+// one reply in three is broken" and still ship every one of them. This runs the
+// cheap half of that on the live path, and replaces a failing message with the
+// deterministic floor.
+//
+// Which checks: the unambiguous string tests only — feminine self-reference,
+// how she addresses him, and a leaked-reasoning test. Not length, which is
+// path-specific, and not ontopic, which compares against fragments the fixture
+// supplies and runtime does not have. Not hisgender either — see guardSpoken.
+//
+// No retry. A retry doubles the latency on the exact turn that is already going
+// badly, and on the nudge path the moment has passed.
+//
+// The known cost, written down because it is real: a wrongly flagged good reply
+// is replaced by a flatter stub one. That is the right trade — a stub sentence
+// is dull, a leaked reasoning trace is broken — but it means these checks can
+// no longer be tuned for sensitivity alone.
+
+// checkLeak — the name reported when the model's scaffolding reaches the text.
+const checkLeak = "leak"
+
+// leakPatterns — reasoning and protocol that belongs to the model, not to him.
+// The resident model is a Thinking variant, so an unclosed reasoning block is
+// the failure mode, not a hypothetical (Vikunja #398).
+var leakPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)<\s*/?\s*think`),
+ regexp.MustCompile(`(?i)thinking\s*(process|:)`),
+ regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
+ // Raw contract JSON: the parser already unwraps a good one, so a body that
+ // still carries the keys is one it could not read.
+ regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
+ // The persona block quoted back at him.
+ regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
+}
+
+// checkPersonaLeak reports whether the model's own scaffolding is in the text.
+func checkPersonaLeak(body string) (string, bool) {
+ for _, re := range leakPatterns {
+ if m := re.FindString(body); m != "" {
+ return "leaked " + strings.TrimSpace(m), false
+ }
+ }
+ return "", true
+}
+
+// personaRejects counts what the guard caught, by check name, so the real
+// production rate is knowable rather than inferred from the fixture.
+var personaRejects = struct {
+ mu sync.Mutex
+ by map[string]int
+}{by: map[string]int{}}
+
+func personaRejectCounts() map[string]int {
+ personaRejects.mu.Lock()
+ defer personaRejects.mu.Unlock()
+ out := make(map[string]int, len(personaRejects.by))
+ for k, v := range personaRejects.by {
+ out[k] = v
+ }
+ return out
+}
+
+// guardSpoken checks a phrased message. It returns the failed check and false
+// when the message must not be said; path names the caller, for the log.
+//
+// An empty message passes: the caller already treats that as a failure and
+// falls back on its own, and reporting it as a persona breach would put a
+// misleading line in the count.
+func guardSpoken(path, body string) (string, bool) {
+ if strings.TrimSpace(body) == "" {
+ return "", true
+ }
+ if detail, ok := checkPersonaLeak(body); !ok {
+ return rejectSpoken(path, checkLeak, detail, body), false
+ }
+ // Feminine and address only. HisGender is not run here: it reads a
+ // sentence-initial feminine verb with no pronoun — "записала, что ты выпил
+ // воды" — as a woman being addressed, when it is her own correct
+ // self-reference. Offline that is a point of score; on this path it would
+ // replace a good reply with a stub one on every fact she confirms.
+ for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
+ if !r.Pass {
+ return rejectSpoken(path, r.Name, r.Detail, body), false
+ }
+ }
+ return "", true
+}
+
+// rejectSpoken logs what she nearly said and counts it. The whole text, not a
+// prefix: the point of the log line is that the failure can be read back later
+// and argued with.
+func rejectSpoken(path, check, detail, body string) string {
+ personaRejects.mu.Lock()
+ personaRejects.by[check]++
+ personaRejects.mu.Unlock()
+ log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
+ return check
+}
+
+// guardNudge checks a phrased nudge and falls back to the deterministic floor
+// when it fails. The nudge path, unlike the reply path, cannot ask again: the
+// tick has already decided she speaks, so the choice is the floor's wording or
+// a broken sentence.
+func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
+ if _, ok := guardSpoken("nudge", pn.Body); ok {
+ return pn
+ }
+ stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
+ if err != nil {
+ // The Stub is templates over the candidate and does not fail. If it
+ // somehow does, the model's text is still what the rule decided to
+ // say, and saying nothing is the worse outcome.
+ return pn
+ }
+ return stub
+}
diff --git a/cmd/mavend/personaguard_test.go b/cmd/mavend/personaguard_test.go
new file mode 100644
index 0000000..26c599a
--- /dev/null
+++ b/cmd/mavend/personaguard_test.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/kami/maven/internal/delivery"
+ "github.com/kami/maven/internal/loop"
+)
+
+func TestGuardPassesWhatSheShouldSay(t *testing.T) {
+ good := []string{
+ "записала: купить хлеб.",
+ "поняла, напомню в 11:00.",
+ "ты не пил воду с утра.",
+ "я рада, что получилось.",
+ "",
+ }
+ for _, body := range good {
+ if check, ok := guardSpoken("test", body); !ok {
+ t.Errorf("guardSpoken(%q) rejected on %s", body, check)
+ }
+ }
+}
+
+func TestGuardStopsWhatSheShouldNot(t *testing.T) {
+ bad := []struct {
+ body string
+ want string
+ }{
+ {"он просил воду попей воды.", checkLeak},
+ {"Thinking Process: он давно не пил.", checkLeak},
+ {`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
+ {"я напомнил тебе про воду.", "feminine"},
+ {"вы давно не пили воду.", "address"},
+ }
+ for _, c := range bad {
+ check, ok := guardSpoken("test", c.body)
+ if ok {
+ t.Errorf("guardSpoken(%q) let it through", c.body)
+ continue
+ }
+ if check != c.want {
+ t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
+ }
+ }
+}
+
+func TestGuardCountsWhatItCaught(t *testing.T) {
+ before := personaRejectCounts()[checkLeak]
+ if _, ok := guardSpoken("test", "…"); ok {
+ t.Fatal("a leaked reasoning block was let through")
+ }
+ if after := personaRejectCounts()[checkLeak]; after != before+1 {
+ t.Errorf("leak count %d; want %d", after, before+1)
+ }
+}
+
+// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
+// deterministic wording, not dropped and not retried.
+func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
+ cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
+ bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
+ got := guardNudge(bad, cand)
+ if got.Body == bad.Body {
+ t.Fatal("the broken nudge was delivered unchanged")
+ }
+ if strings.TrimSpace(got.Body) == "" {
+ t.Fatal("the nudge was dropped rather than re-worded")
+ }
+ good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
+ if guardNudge(good, cand).Body != good.Body {
+ t.Error("a good nudge was replaced")
+ }
+}
diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go
index 20967dd..496afdb 100644
--- a/cmd/mavend/replier_llm.go
+++ b/cmd/mavend/replier_llm.go
@@ -30,5 +30,10 @@ func (r *llmReplier) Reply(d router.Decision) string {
if err != nil || out == "" {
return r.stub.Reply(d)
}
+ // The persona checks, on the live path (personaguard.go). A reply that
+ // leaks reasoning or calls him "вы" is worse than a flat one.
+ if _, ok := guardSpoken("reply", out); !ok {
+ return r.stub.Reply(d)
+ }
return out
}
diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go
index 8926a47..04ff8af 100644
--- a/cmd/mavend/tick.go
+++ b/cmd/mavend/tick.go
@@ -176,6 +176,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
t.queueNudge(ctx, cand, state, now)
} else {
pn, err := t.phraser.PhraseNudge(ctx, *cand)
+ if err == nil {
+ pn = guardNudge(pn, *cand)
+ }
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
} else {
diff --git a/internal/phraser/eval/checks.go b/internal/phraser/eval/checks.go
index 4fffc46..fdbbe48 100644
--- a/internal/phraser/eval/checks.go
+++ b/internal/phraser/eval/checks.go
@@ -67,6 +67,19 @@ func RunChecks(c Case, body, mood string) []Result {
}
}
+// Feminine, HisGender and Address expose three checks one at a time, so the
+// daemon can run them on a phrased message before he hears it (Vikunja #399).
+// Only these three: they are unambiguous string tests with nothing to compare
+// against, while length is path-specific and ontopic needs the fixture's
+// expected fragments, which do not exist at runtime.
+func Feminine(body string) Result { return checkFeminine(body) }
+
+// HisGender — see checkHisGender.
+func HisGender(body string) Result { return checkHisGender(body) }
+
+// Address — see checkAddress.
+func Address(body string) Result { return checkAddress(body) }
+
func checkMood(mood string) Result {
if Moods[mood] {
return Result{CheckMood, true, ""}