arkaos 2.31.0 → 2.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +76 -0
  4. package/arka/skills/checkpoint/SKILL.md +79 -0
  5. package/config/constitution.yaml +193 -0
  6. package/config/hooks/pre-tool-use.sh +18 -2
  7. package/config/hooks/session-start.sh +7 -0
  8. package/config/hooks/stop.sh +72 -0
  9. package/config/hooks/user-prompt-submit.sh +25 -0
  10. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  11. package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
  12. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  13. package/core/governance/__init__.py +7 -1
  14. package/core/governance/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/core/governance/__pycache__/dod_gate.cpython-313.pyc +0 -0
  16. package/core/governance/__pycache__/kb_cite_check.cpython-313.pyc +0 -0
  17. package/core/governance/__pycache__/learning_detector.cpython-313.pyc +0 -0
  18. package/core/governance/__pycache__/sycophancy_detector.cpython-313.pyc +0 -0
  19. package/core/governance/dod_gate.py +192 -0
  20. package/core/governance/kb_cite_check.py +129 -0
  21. package/core/governance/learning_detector.py +221 -0
  22. package/core/governance/sycophancy_detector.py +169 -0
  23. package/core/orchestration/__pycache__/checkpoint.cpython-313.pyc +0 -0
  24. package/core/orchestration/checkpoint.py +157 -0
  25. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
  27. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  28. package/core/workflow/flow_enforcer.py +144 -3
  29. package/knowledge/commands-registry.json +1 -1
  30. package/package.json +2 -2
  31. package/pyproject.toml +1 -1
  32. package/knowledge/commands-registry.json.bak +0 -2791
@@ -0,0 +1,192 @@
1
+ """Definition of Done gate for ArkaOS deliveries (PR14 v2.36.0).
2
+
3
+ Implements the `definition-of-done-per-domain` NON-NEGOTIABLE rule from
4
+ PR10 constitution. Loads the per-domain checklists from
5
+ ``config/constitution.yaml`` (`definition_of_done` section) and evaluates
6
+ an agent-supplied status report against them.
7
+
8
+ The gate does NOT execute the underlying checks itself (e.g. it does not
9
+ run Playwright or lint). It validates that the agent has reported a
10
+ status for each item in the relevant domain — passed, skipped, failed,
11
+ or not-applicable — and produces a verdict.
12
+
13
+ Hard items: ALL must be ``passed`` for the verdict to be APPROVED.
14
+ Soft items: may be ``skipped`` without rejection, but a ``failed``
15
+ soft item is still recorded.
16
+
17
+ This mirrors the audit pattern used by Marta in QG: the agent presents
18
+ its work + status claims, and the gate verifies completeness against
19
+ the canonical checklist. False reporting is a different problem (the
20
+ sycophancy detector + critic pass in QG catch that).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import asdict, dataclass, field
26
+ from pathlib import Path
27
+ from typing import Literal
28
+
29
+ import yaml
30
+
31
+ ItemStatus = Literal["passed", "skipped", "failed", "not-applicable"]
32
+
33
+ _CONSTITUTION_PATH = (
34
+ Path(__file__).resolve().parents[2] / "config" / "constitution.yaml"
35
+ )
36
+
37
+ _VALID_STATUSES: frozenset[str] = frozenset({
38
+ "passed", "skipped", "failed", "not-applicable",
39
+ })
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class DODItem:
44
+ """One Definition-of-Done checklist item from the constitution."""
45
+
46
+ id: str
47
+ rule: str
48
+ hard: bool
49
+ conditional: str | None = None
50
+
51
+
52
+ @dataclass
53
+ class DODVerdict:
54
+ """Structured outcome of a DOD evaluation."""
55
+
56
+ domain: str
57
+ approved: bool
58
+ failed_hard_items: list[str] = field(default_factory=list)
59
+ failed_soft_items: list[str] = field(default_factory=list)
60
+ skipped_hard_items: list[str] = field(default_factory=list)
61
+ skipped_soft_items: list[str] = field(default_factory=list)
62
+ unreported_hard_items: list[str] = field(default_factory=list)
63
+ unreported_soft_items: list[str] = field(default_factory=list)
64
+ not_applicable: list[str] = field(default_factory=list)
65
+
66
+ def to_dict(self) -> dict:
67
+ return asdict(self)
68
+
69
+ def summary(self) -> str:
70
+ parts: list[str] = []
71
+ if self.approved:
72
+ parts.append("APPROVED")
73
+ else:
74
+ parts.append("REJECTED")
75
+ if self.failed_hard_items:
76
+ parts.append(f"failed-hard={','.join(self.failed_hard_items)}")
77
+ if self.unreported_hard_items:
78
+ parts.append(f"unreported-hard={','.join(self.unreported_hard_items)}")
79
+ if self.skipped_hard_items:
80
+ parts.append(f"skipped-hard={','.join(self.skipped_hard_items)}")
81
+ return " | ".join(parts)
82
+
83
+
84
+ def load_definition_of_done(
85
+ domain: str, constitution_path: Path | None = None
86
+ ) -> list[DODItem]:
87
+ """Return the DOD items for *domain* (universal items + domain-specific).
88
+
89
+ ``domain`` is one of: ``frontend``, ``backend``, ``content``. Other
90
+ names raise ``ValueError`` (no implicit fallback — agents must opt
91
+ in to a known domain). Universal items are always merged in front
92
+ of the domain-specific items.
93
+ """
94
+ path = constitution_path or _CONSTITUTION_PATH
95
+ with open(path, encoding="utf-8") as fh:
96
+ data = yaml.safe_load(fh)
97
+ dod = data.get("definition_of_done") or {}
98
+ if domain not in dod or domain == "description" or domain == "universal":
99
+ valid = [
100
+ k for k in dod.keys()
101
+ if k not in {"description", "universal"}
102
+ ]
103
+ raise ValueError(
104
+ f"unknown DOD domain {domain!r}; choose from {sorted(valid)}"
105
+ )
106
+
107
+ items: list[DODItem] = []
108
+ for raw in (dod.get("universal", {}).get("items") or []):
109
+ items.append(_to_item(raw))
110
+ for raw in (dod[domain].get("items") or []):
111
+ items.append(_to_item(raw))
112
+ return items
113
+
114
+
115
+ def evaluate_dod(
116
+ domain: str,
117
+ item_statuses: dict[str, str],
118
+ constitution_path: Path | None = None,
119
+ ) -> DODVerdict:
120
+ """Validate *item_statuses* against the DOD checklist for *domain*.
121
+
122
+ Inputs:
123
+ domain — one of "frontend", "backend", "content"
124
+ item_statuses — mapping {item_id: "passed"|"skipped"|"failed"|
125
+ "not-applicable"} reported by the agent
126
+ constitution_path — optional override (test injection)
127
+
128
+ Returns a :class:`DODVerdict`. Verdict is APPROVED iff:
129
+ - every hard item is reported AND status == "passed"
130
+ (or "not-applicable" with explicit justification — recorded
131
+ but does not block)
132
+
133
+ Soft items: never block. Their statuses are recorded for telemetry.
134
+ Unknown status values raise ValueError.
135
+ """
136
+ items = load_definition_of_done(domain, constitution_path)
137
+ for status in item_statuses.values():
138
+ if status not in _VALID_STATUSES:
139
+ raise ValueError(
140
+ f"invalid status {status!r}; must be one of {sorted(_VALID_STATUSES)}"
141
+ )
142
+
143
+ verdict = DODVerdict(domain=domain, approved=True)
144
+ for item in items:
145
+ reported = item_statuses.get(item.id)
146
+ if reported is None:
147
+ if item.hard:
148
+ verdict.unreported_hard_items.append(item.id)
149
+ verdict.approved = False
150
+ else:
151
+ verdict.unreported_soft_items.append(item.id)
152
+ continue
153
+ if reported == "passed":
154
+ continue
155
+ if reported == "not-applicable":
156
+ verdict.not_applicable.append(item.id)
157
+ continue
158
+ if reported == "skipped":
159
+ if item.hard:
160
+ verdict.skipped_hard_items.append(item.id)
161
+ verdict.approved = False
162
+ else:
163
+ verdict.skipped_soft_items.append(item.id)
164
+ continue
165
+ if reported == "failed":
166
+ if item.hard:
167
+ verdict.failed_hard_items.append(item.id)
168
+ verdict.approved = False
169
+ else:
170
+ verdict.failed_soft_items.append(item.id)
171
+ return verdict
172
+
173
+
174
+ def list_supported_domains(constitution_path: Path | None = None) -> list[str]:
175
+ """Enumerate the domains the constitution defines DOD checklists for."""
176
+ path = constitution_path or _CONSTITUTION_PATH
177
+ with open(path, encoding="utf-8") as fh:
178
+ data = yaml.safe_load(fh)
179
+ dod = data.get("definition_of_done") or {}
180
+ return sorted(
181
+ k for k in dod.keys()
182
+ if k not in {"description", "universal"}
183
+ )
184
+
185
+
186
+ def _to_item(raw: dict) -> DODItem:
187
+ return DODItem(
188
+ id=str(raw["id"]),
189
+ rule=str(raw["rule"]),
190
+ hard=bool(raw.get("hard", True)),
191
+ conditional=raw.get("conditional"),
192
+ )
@@ -0,0 +1,129 @@
1
+ """KB citation check for ArkaOS responses (PR18 v2.40.0).
2
+
3
+ Soft-block classifier. Inspects an assistant response for KB
4
+ citations (`[[wikilink]]`, `[knowledge:` marker, `file.ext:line`
5
+ code references) and reports whether the response honored the
6
+ KB-first contract on an ArkaOS topic.
7
+
8
+ Non-blocking by design — hooks consume CitationResult and decide
9
+ whether to surface a nudge to the next turn's additionalContext.
10
+ This module never raises; on malformed input it returns a passed
11
+ "trivial" verdict so the caller never crashes the hook pipeline.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ # ─── Patterns ───────────────────────────────────────────────────────────
20
+
21
+ _WIKILINK_PATTERN: re.Pattern[str] = re.compile(r"\[\[([^\[\]]+)\]\]")
22
+ _KNOWLEDGE_PATTERN: re.Pattern[str] = re.compile(r"\[knowledge:", re.IGNORECASE)
23
+ # File:line — requires at least one path separator. Quantifiers are bounded
24
+ # to prevent catastrophic backtracking on pathological input (PR18 security
25
+ # review: unbounded variant blew past Stop's 5s budget by ~8x on 100KB of
26
+ # `a/a/a/...` with no trailing extension).
27
+ _FILE_LINE_PATTERN: re.Pattern[str] = re.compile(
28
+ r"(?:[\w._-]{1,64}/){1,8}[\w._-]{1,64}\.\w{1,8}:\d{1,6}"
29
+ )
30
+ # Hard upper bound on input scanned for citations. Anything beyond is
31
+ # almost certainly a code dump / log paste and not human prose — skip the
32
+ # more expensive file-line regex entirely above this threshold.
33
+ _MAX_SCAN_CHARS: int = 50_000
34
+
35
+ # ArkaOS topic keywords. Match is case-insensitive substring.
36
+ _ARKAOS_KEYWORDS: frozenset[str] = frozenset({
37
+ "arkaos", "arka", "constitution", "quality gate", "synapse",
38
+ "conclave", "forge", "dreaming", "marta", "eduardo", "francisca",
39
+ "marco", "helena", "sofia", "paulo", "luna", "valentina",
40
+ "tomas", "ricardo", "clara", "daniel", "carolina", "tiago",
41
+ "ines", "rafael", "beatriz", "miguel", "rodrigo",
42
+ "non-negotiable", "tier 0", "squad lead",
43
+ "core/governance", "core/synapse", "core/cognition",
44
+ "core/workflow", "mcp__obsidian", "kb-first",
45
+ "departments/", "agent yaml", "obsidian vault",
46
+ })
47
+
48
+ _BYPASS_DEFAULTS: tuple[str, ...] = ("[arka:trivial]",)
49
+ _TRIVIAL_WORD_THRESHOLD: int = 15
50
+ _TOPIC_THRESHOLD: float = 0.4
51
+ _SUGGESTION_TEXT: str = (
52
+ "KB-first — last response had no citation on ArkaOS topic. "
53
+ "Use @[[note-name]], /kb search, or mcp__obsidian__search_notes "
54
+ "to ground the next answer."
55
+ )
56
+
57
+
58
+ # ─── Result ─────────────────────────────────────────────────────────────
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CitationResult:
63
+ """Verdict of a citation check. Immutable; safe to log as JSON."""
64
+ passed: bool
65
+ reason: str
66
+ suggestion: str | None
67
+ citation_count: int
68
+ topic_score: float
69
+
70
+
71
+ # ─── Public API ─────────────────────────────────────────────────────────
72
+
73
+
74
+ def check_citation(
75
+ response_text: str,
76
+ *,
77
+ topic_keywords: frozenset[str] | None = None,
78
+ bypass_markers: tuple[str, ...] = _BYPASS_DEFAULTS,
79
+ ) -> CitationResult:
80
+ """Classify an assistant response for KB citation discipline."""
81
+ text = response_text or ""
82
+ citation_count = _count_citations(text)
83
+ topic_score = _compute_topic_score(text, topic_keywords or _ARKAOS_KEYWORDS)
84
+
85
+ if _has_bypass_marker(text, bypass_markers):
86
+ return CitationResult(True, "trivial", None, citation_count, topic_score)
87
+
88
+ if _is_trivial_length(text):
89
+ return CitationResult(True, "trivial", None, citation_count, topic_score)
90
+
91
+ if citation_count > 0:
92
+ return CitationResult(True, "cited", None, citation_count, topic_score)
93
+
94
+ if topic_score < _TOPIC_THRESHOLD:
95
+ return CitationResult(True, "off-topic", None, 0, topic_score)
96
+
97
+ return CitationResult(False, "missing", _SUGGESTION_TEXT, 0, topic_score)
98
+
99
+
100
+ # ─── Helpers (private) ──────────────────────────────────────────────────
101
+
102
+
103
+ def _has_bypass_marker(text: str, markers: tuple[str, ...]) -> bool:
104
+ return any(marker in text for marker in markers)
105
+
106
+
107
+ def _is_trivial_length(text: str) -> bool:
108
+ return len(text.split()) < _TRIVIAL_WORD_THRESHOLD
109
+
110
+
111
+ def _count_citations(text: str) -> int:
112
+ # Truncate to _MAX_SCAN_CHARS first; the file-line regex is the only
113
+ # backtracking-prone pattern and the bounded quantifiers (capped at 8
114
+ # segments × 64 chars each) combined with this slice make all three
115
+ # patterns safe to run unconditionally below.
116
+ scan = text if len(text) <= _MAX_SCAN_CHARS else text[:_MAX_SCAN_CHARS]
117
+ wikilinks = len(_WIKILINK_PATTERN.findall(scan))
118
+ knowledge = 1 if _KNOWLEDGE_PATTERN.search(scan) else 0
119
+ files = len(_FILE_LINE_PATTERN.findall(scan))
120
+ return wikilinks + knowledge + files
121
+
122
+
123
+ def _compute_topic_score(text: str, keywords: frozenset[str]) -> float:
124
+ if not keywords:
125
+ return 0.0
126
+ lower = text.lower()
127
+ hits = sum(1 for k in keywords if k.lower() in lower)
128
+ denom = max(1, len(keywords) // 10)
129
+ return min(1.0, hits / denom)
@@ -0,0 +1,221 @@
1
+ """Correction-signal detector for ArkaOS hybrid learning (PR16 v2.38.0).
2
+
3
+ Implements the `hybrid-learning` NON-NEGOTIABLE rule from PR10
4
+ constitution. The detector scans a user message for signs that the user
5
+ is establishing a permanent rule (rather than a one-off comment), and
6
+ classifies the signal so the orchestrator can:
7
+
8
+ * IMPLICIT — low-leverage correction, auto-save with low confidence
9
+ * EXPLICIT — high-leverage correction, surface Marta confirmation
10
+ question before saving
11
+
12
+ The detector is heuristic (regex + magnitude). It never writes memory
13
+ files itself — it only emits a structured verdict the Stop hook records
14
+ and the orchestrator acts upon.
15
+
16
+ Conclave 2026-05-13 brainstorm rule:
17
+ * Implicit auto-detect with confidence scoring (default)
18
+ * Explicit Marta-led confirmation for high-leverage rules
19
+ * Marta is the owner of the learning loop
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import asdict, dataclass, field
26
+ from typing import Literal
27
+
28
+ CorrectionMode = Literal["explicit", "implicit", "none"]
29
+ CorrectionKind = Literal["rule", "preference", "one-off", "none"]
30
+
31
+ # Absolute-language cues — strong signal that user is asserting a rule.
32
+ _ABSOLUTE_CUES: tuple[re.Pattern, ...] = tuple(
33
+ re.compile(p, re.IGNORECASE) for p in [
34
+ r"\bsempre\b",
35
+ r"\bnunca\b",
36
+ r"\btodas?\s+as?\s+vezes\b",
37
+ r"\bem\s+todos?\s+os?\s+casos\b",
38
+ r"\bsem\s+exce[cç][oõ]es\b",
39
+ r"\bno\s+exceptions\b",
40
+ r"\balways\b",
41
+ r"\bnever\b",
42
+ r"\bevery\s+time\b",
43
+ r"\bin\s+all\s+cases\b",
44
+ r"\bNON-NEGOTIABLE\b",
45
+ r"\bn[aã]o[\s-]+negoci[aá]vel\b",
46
+ r"\bmandatory\b",
47
+ r"\bobrigat[oó]rio\b",
48
+ r"\bregra\s+permanente\b",
49
+ r"\bpermanent\s+rule\b",
50
+ r"\bguarda\s+isto\b",
51
+ r"\bsave\s+this\b",
52
+ r"\bencode\s+this\b",
53
+ r"\benforce\s+this\b",
54
+ r"\b(daqui\s+)?para\s+a\s+frente\b",
55
+ r"\bgoing\s+forward\b",
56
+ ]
57
+ )
58
+
59
+ # Correction verbs — "stop doing X" / "I want Y instead".
60
+ _CORRECTION_VERBS: tuple[re.Pattern, ...] = tuple(
61
+ re.compile(p, re.IGNORECASE) for p in [
62
+ r"\bn[aã]o\s+(quero|fa[cç]as?|continues?)\b",
63
+ r"\bp[aá]ra\s+de\b",
64
+ r"\bdeixa\s+de\b",
65
+ r"\bevita\b",
66
+ r"\bstop\s+(doing|saying)\b",
67
+ r"\bdon[' ]t\s+(do|say|use|assume)\b",
68
+ r"\bavoid\b",
69
+ r"\bem\s+vez\s+de\b",
70
+ r"\binstead\s+of\b",
71
+ r"\bn[aã]o\s+é\s+(isso|assim)\b",
72
+ r"\bwrong\b",
73
+ r"\bnot\s+like\s+that\b",
74
+ ]
75
+ )
76
+
77
+ # Self-affirmation cues — "I prefer X" / "what I want is".
78
+ _PREFERENCE_CUES: tuple[re.Pattern, ...] = tuple(
79
+ re.compile(p, re.IGNORECASE) for p in [
80
+ r"\b(eu\s+)?prefiro\b",
81
+ r"\b(eu\s+)?gosto\s+(de|mais)\b",
82
+ r"\bI\s+prefer\b",
83
+ r"\bI\s+like\b",
84
+ r"\bI\s+want\b",
85
+ r"\bo\s+que\s+(eu\s+)?quero\s+é\b",
86
+ r"\bwhat\s+I\s+want\s+is\b",
87
+ ]
88
+ )
89
+
90
+ # Magnitude thresholds.
91
+ _MIN_CORRECTION_CHARS = 60
92
+ _HIGH_LEVERAGE_CHARS = 200
93
+
94
+
95
+ @dataclass
96
+ class CorrectionSignal:
97
+ """Structured verdict on a potential correction message."""
98
+
99
+ mode: CorrectionMode
100
+ kind: CorrectionKind
101
+ confidence: float # 0.0 .. 1.0
102
+ is_high_leverage: bool
103
+ signals: list[str] = field(default_factory=list)
104
+ suggested_memory_type: str = "" # "feedback" | "preference" | ""
105
+ message_length: int = 0
106
+
107
+ def to_dict(self) -> dict:
108
+ return asdict(self)
109
+
110
+ def should_save(self) -> bool:
111
+ """Whether the orchestrator should write a memory file."""
112
+ return self.confidence >= 0.5
113
+
114
+ def should_confirm(self) -> bool:
115
+ """Whether Marta should ask the user to confirm before saving."""
116
+ return self.is_high_leverage or self.confidence >= 0.85
117
+
118
+
119
+ def detect_correction_signal(text: str) -> CorrectionSignal:
120
+ """Inspect *text* and return a CorrectionSignal verdict.
121
+
122
+ Confidence scale (cumulative across signal types, capped at 1.0):
123
+ 0.0 no signal
124
+ 0.4 short message with correction verb
125
+ 0.55 absolute-language cue present (sempre/nunca/etc.)
126
+ 0.7 correction verb + absolute-language cue
127
+ 0.85 long message with multiple cue types
128
+ 1.0 explicit "guarda isto / save this / encode this"
129
+ """
130
+ stripped = (text or "").strip()
131
+ if not stripped:
132
+ return CorrectionSignal(
133
+ mode="none", kind="none", confidence=0.0, is_high_leverage=False
134
+ )
135
+
136
+ signals: list[str] = []
137
+ confidence = 0.0
138
+ kind: CorrectionKind = "none"
139
+
140
+ # Absolute-language cues (strong rule signal).
141
+ abs_hits = [p.pattern for p in _ABSOLUTE_CUES if p.search(stripped)]
142
+ if abs_hits:
143
+ signals.append("absolute-language")
144
+ confidence = max(confidence, 0.55)
145
+ kind = "rule"
146
+
147
+ # Correction verbs.
148
+ correction_hits = [p.pattern for p in _CORRECTION_VERBS if p.search(stripped)]
149
+ if correction_hits:
150
+ signals.append("correction-verb")
151
+ confidence = max(confidence, 0.4)
152
+ if kind == "none":
153
+ kind = "rule"
154
+
155
+ # Preference cues — softer signal, "preference" not "rule".
156
+ pref_hits = [p.pattern for p in _PREFERENCE_CUES if p.search(stripped)]
157
+ if pref_hits:
158
+ signals.append("preference-cue")
159
+ confidence = max(confidence, 0.35)
160
+ if kind == "none":
161
+ kind = "preference"
162
+
163
+ # Combination boosts confidence.
164
+ if abs_hits and correction_hits:
165
+ signals.append("rule-plus-correction-combo")
166
+ confidence = max(confidence, 0.7)
167
+
168
+ # Explicit save-this verbs are 1.0.
169
+ save_now = re.search(
170
+ r"\b(guarda\s+isto|save\s+this|encode\s+this|enforce\s+this|"
171
+ r"regra\s+permanente|permanent\s+rule|going\s+forward)\b",
172
+ stripped, re.IGNORECASE,
173
+ )
174
+ if save_now:
175
+ signals.append("explicit-save-verb")
176
+ confidence = 1.0
177
+ kind = "rule"
178
+
179
+ # Magnitude boost — long substantive corrections are more likely rules.
180
+ msg_len = len(stripped)
181
+ is_high_leverage = False
182
+ if msg_len >= _HIGH_LEVERAGE_CHARS and (abs_hits or correction_hits):
183
+ signals.append("high-magnitude")
184
+ confidence = max(confidence, 0.85)
185
+ is_high_leverage = True
186
+
187
+ if msg_len < _MIN_CORRECTION_CHARS and not save_now and not abs_hits:
188
+ # Too short to be a substantive rule, unless it explicitly says "save"
189
+ # OR contains an absolute-language cue (e.g. "always", "never",
190
+ # "NON-NEGOTIABLE"). Those are intentional declarative rules even
191
+ # when phrased compactly — capping their confidence would silence
192
+ # the user's clear intent.
193
+ confidence = min(confidence, 0.4)
194
+
195
+ # NON-NEGOTIABLE keyword always escalates to high-leverage.
196
+ if re.search(r"\bNON-NEGOTIABLE\b|\bn[aã]o[\s-]+negoci[aá]vel\b", stripped, re.IGNORECASE):
197
+ is_high_leverage = True
198
+
199
+ # Mode selection.
200
+ if confidence < 0.35:
201
+ mode: CorrectionMode = "none"
202
+ elif is_high_leverage or confidence >= 0.85:
203
+ mode = "explicit"
204
+ else:
205
+ mode = "implicit"
206
+
207
+ suggested = ""
208
+ if kind == "rule" and confidence >= 0.5:
209
+ suggested = "feedback"
210
+ elif kind == "preference" and confidence >= 0.35:
211
+ suggested = "preference"
212
+
213
+ return CorrectionSignal(
214
+ mode=mode,
215
+ kind=kind,
216
+ confidence=round(confidence, 2),
217
+ is_high_leverage=is_high_leverage,
218
+ signals=signals,
219
+ suggested_memory_type=suggested,
220
+ message_length=msg_len,
221
+ )