Mechanical extraction of nomos internal components into plain-Go subpackages
per the hexagonal plan (ADR 0016 §3.1 rule 3):
turngate/ — per-session turn serialization (plan 2026-08-03 F1)
retrycap/ — per-turn run retry cap (maxRunRetries=3)
messagequeue/ — operator-message queue for busy-turn re-entry (F2)
assent/ — chat-assent detection (isAssent, isTypedConfirmation,
ExtractPendingApprovals), decoupled from agent via
[]string input instead of persistedCall
session/ — store (chat sessions, plan execution, DB persistence),
migration runner + local emitEvent to break adapter
dependency
internal/migrate/ — shared migration runner extracted from postgres pool,
used by both the oikos postgres adapter and session tests.
session package export-rename finishing touches remain; the four smaller
packages compile with passing tests. Depguard rules and ADR-0016 leaf-note
update deferred to a followup. VERSION 0.35.1.
144 lines
5.6 KiB
Go
144 lines
5.6 KiB
Go
package assent
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// Chat-assent approval: the operator authorizes a proposed action by
|
||
// replying normally in chat ("go ahead", "yes", "do it") instead of clicking
|
||
// a separate Approve button. This is deterministic (not LLM-judged) so it
|
||
// can't be talked around by a model that misreads intent, and it only ever
|
||
// looks at the assistant turn immediately preceding the operator's reply —
|
||
// an old "yes" from three messages ago can never retroactively approve
|
||
// something new. Destructive-risk actions are excluded: they always need the
|
||
// explicit typed-confirmation flow, never loose assent.
|
||
|
||
// PendingApproval is one gated action proposed in the immediately-preceding
|
||
// assistant turn, extracted from its tool_result text.
|
||
type PendingApproval struct {
|
||
ExecID string
|
||
Destructive bool
|
||
}
|
||
|
||
// executionQueuedRE matches the "execution <uuid> queued" phrasing shared by
|
||
// the run and request_execution/pct_create tool result messages.
|
||
var executionQueuedRE = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+queued`)
|
||
|
||
// extractPendingApprovals scans the tool results of one assistant turn for
|
||
// gated actions that are still awaiting a decision.
|
||
func ExtractPendingApprovals(resultTexts []string) []PendingApproval {
|
||
var out []PendingApproval
|
||
for _, text := range resultTexts {
|
||
m := executionQueuedRE.FindStringSubmatch(text)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
out = append(out, PendingApproval{
|
||
ExecID: m[1],
|
||
Destructive: strings.Contains(strings.ToUpper(text), "DESTRUCTIVE"),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// negationWords, checked first: any of these anywhere in the message means
|
||
// the reply is NOT assent, even if a positive word also appears (e.g. "no,
|
||
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
|
||
// alone should also block a stray "yes" a sentence later — checking negation
|
||
// first and returning false errs toward re-confirming rather than assuming
|
||
// consent, per "when in doubt, escalate"). Includes contracted negatives
|
||
// ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I
|
||
// haven't confirmed anything yet" was reading as an explicit confirmation
|
||
// because none of the contracted forms were covered, only "don't"/"do not".
|
||
// Deliberately does NOT include a bare "not": that's broad enough to false-
|
||
// negative ordinary assent ("go ahead, this is not risky") — the specific
|
||
// contracted-verb forms below are unambiguous negation on their own.
|
||
var negationWords = []string{
|
||
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
|
||
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
|
||
"haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot",
|
||
"won't", "wouldn't", "shouldn't", "didn't", "doesn't",
|
||
}
|
||
|
||
// assentWords, checked only if no negation matched.
|
||
var assentWords = []string{
|
||
"go ahead", "goahead", "yes", "yep", "yeah", "yup", "do it", "proceed",
|
||
"approve", "approved", "confirm", "confirmed", "ship it", "sounds good",
|
||
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
|
||
}
|
||
|
||
// wordTokenRe splits a message into lowercase word tokens. Apostrophes
|
||
// (straight ' and curly ’) stay attached to their word so "don't"/"haven't"
|
||
// tokenize as one token, not two.
|
||
var wordTokenRe = regexp.MustCompile(`[a-z0-9'’]+`)
|
||
|
||
func tokenize(msg string) []string {
|
||
return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "’", "'")), -1)
|
||
}
|
||
|
||
// containsPhrase reports whether phrase (one or more words) appears as a
|
||
// consecutive run of WHOLE tokens in tokens — never a mid-word substring
|
||
// match. This is the fix for a real false positive found live: the old
|
||
// substring check (`strings.Contains(m, "yes")`) matched "yes" inside
|
||
// "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard
|
||
// for word boundaries. Negation already used a word-boundary check
|
||
// (space-padded); assent/confirm words didn't — this brings both onto the
|
||
// same, more robust tokenized comparison instead of ad-hoc string padding.
|
||
func containsPhrase(tokens []string, phrase string) bool {
|
||
words := strings.Fields(phrase)
|
||
if len(words) == 0 || len(words) > len(tokens) {
|
||
return false
|
||
}
|
||
for i := 0; i+len(words) <= len(tokens); i++ {
|
||
match := true
|
||
for j, w := range words {
|
||
if tokens[i+j] != w {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
if match {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isAssent reports whether msg is a plain-language authorization of a
|
||
// pending proposal. Deliberately simple and auditable: a fixed word list,
|
||
// not a model judgment call, so behavior is predictable and can't be
|
||
// prompt-injected via the pending action's own content.
|
||
func IsAssent(msg string) bool {
|
||
tokens := tokenize(msg)
|
||
for _, w := range negationWords {
|
||
if containsPhrase(tokens, w) {
|
||
return false
|
||
}
|
||
}
|
||
for _, w := range assentWords {
|
||
if containsPhrase(tokens, w) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isTypedConfirmation reports whether msg is an explicit confirmation strong
|
||
// enough to grant a DESTRUCTIVE pending action. Deliberately a separate,
|
||
// stricter check from isAssent: a bare "yes"/"go ahead"/"proceed" must never
|
||
// grant something destructive, only an explicit "confirm" statement does —
|
||
// this is the typed-confirmation phrase SOUL.md tells the operator to use
|
||
// ("I confirm destroy 135"). Still negation-aware for the same reason as
|
||
// isAssent: "don't confirm yet" must not accidentally match.
|
||
func IsTypedConfirmation(msg string) bool {
|
||
tokens := tokenize(msg)
|
||
for _, w := range negationWords {
|
||
if containsPhrase(tokens, w) {
|
||
return false
|
||
}
|
||
}
|
||
return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed")
|
||
}
|
||
|