arkaos 2.39.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.
- package/VERSION +1 -1
- package/config/hooks/stop.sh +45 -0
- package/config/hooks/user-prompt-submit.sh +25 -0
- package/core/governance/__init__.py +7 -1
- package/core/governance/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/dod_gate.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/kb_cite_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/learning_detector.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/sycophancy_detector.cpython-313.pyc +0 -0
- package/core/governance/kb_cite_check.py +129 -0
- package/core/orchestration/__pycache__/checkpoint.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.40.0
|
package/config/hooks/stop.sh
CHANGED
|
@@ -113,6 +113,47 @@ try:
|
|
|
113
113
|
except Exception:
|
|
114
114
|
pass
|
|
115
115
|
|
|
116
|
+
# PR18 v2.40.0 — KB citation soft-block. Records whether the closing
|
|
117
|
+
# assistant message cited the vault on an ArkaOS topic. Result is also
|
|
118
|
+
# persisted to /tmp/arkaos-cite/<session>.json so the next UserPromptSubmit
|
|
119
|
+
# can surface a nudge if passed=False. Non-blocking; never raises.
|
|
120
|
+
cite_passed = True
|
|
121
|
+
cite_reason = "trivial"
|
|
122
|
+
cite_count = 0
|
|
123
|
+
cite_topic_score = 0.0
|
|
124
|
+
cite_suggestion: str | None = None
|
|
125
|
+
try:
|
|
126
|
+
from core.governance.kb_cite_check import check_citation
|
|
127
|
+
cr = check_citation(last)
|
|
128
|
+
cite_passed = cr.passed
|
|
129
|
+
cite_reason = cr.reason
|
|
130
|
+
cite_count = cr.citation_count
|
|
131
|
+
cite_topic_score = cr.topic_score
|
|
132
|
+
cite_suggestion = cr.suggestion
|
|
133
|
+
# PR18 security fix: session_id comes from the runtime via stdin JSON
|
|
134
|
+
# and is untrusted. Reuse the shared allowlist before building any
|
|
135
|
+
# filesystem path. Reject (skip write) on anything outside
|
|
136
|
+
# [A-Za-z0-9._-]{1,128} — no `..`, no slashes, no whitespace, no NUL.
|
|
137
|
+
try:
|
|
138
|
+
from core.shared.safe_session_id import safe_session_id as _safe_sid
|
|
139
|
+
safe_sid = _safe_sid(session_id)
|
|
140
|
+
except Exception:
|
|
141
|
+
safe_sid = None
|
|
142
|
+
if safe_sid:
|
|
143
|
+
cite_dir = Path("/tmp/arkaos-cite")
|
|
144
|
+
cite_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
cite_path = cite_dir / f"{safe_sid}.json"
|
|
146
|
+
cite_payload = {
|
|
147
|
+
"passed": cr.passed,
|
|
148
|
+
"reason": cr.reason,
|
|
149
|
+
"suggestion": cr.suggestion,
|
|
150
|
+
"citation_count": cr.citation_count,
|
|
151
|
+
"topic_score": cr.topic_score,
|
|
152
|
+
}
|
|
153
|
+
cite_path.write_text(json.dumps(cite_payload), encoding="utf-8")
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
116
157
|
entry = {
|
|
117
158
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
118
159
|
"session_id": session_id,
|
|
@@ -125,6 +166,10 @@ entry = {
|
|
|
125
166
|
"sycophancy_is_flagged": is_sycophantic,
|
|
126
167
|
"sycophancy_signals": sycophancy_signals,
|
|
127
168
|
"sycophancy_confidence": sycophancy_confidence,
|
|
169
|
+
"kb_cite_passed": cite_passed,
|
|
170
|
+
"kb_cite_reason": cite_reason,
|
|
171
|
+
"kb_cite_count": cite_count,
|
|
172
|
+
"kb_cite_topic_score": cite_topic_score,
|
|
128
173
|
"mode": "warn",
|
|
129
174
|
}
|
|
130
175
|
|
|
@@ -370,9 +370,34 @@ if [ -n "$SESSION_ID" ]; then
|
|
|
370
370
|
fi
|
|
371
371
|
fi
|
|
372
372
|
|
|
373
|
+
# ─── KB citation nudge (PR18 v2.40.0) ────────────────────────────────────
|
|
374
|
+
# Read the cite-check result written by the previous Stop hook. If the
|
|
375
|
+
# last assistant turn was on an ArkaOS topic without any citation, surface
|
|
376
|
+
# the suggestion to the model in this turn's additionalContext. One-shot:
|
|
377
|
+
# the file is deleted after read so the nudge does not repeat across turns.
|
|
378
|
+
_KB_CITE_NUDGE=""
|
|
379
|
+
if [ -n "$SESSION_ID" ]; then
|
|
380
|
+
_CITE_FILE="/tmp/arkaos-cite/${SESSION_ID}.json"
|
|
381
|
+
if [ -f "$_CITE_FILE" ]; then
|
|
382
|
+
if command -v jq &>/dev/null; then
|
|
383
|
+
# NOTE: do not use `// true` here — jq's `//` treats false as needing
|
|
384
|
+
# the default, which would suppress the nudge in the exact case we
|
|
385
|
+
# care about. Read .passed raw and compare to the literal "false".
|
|
386
|
+
_CITE_PASSED=$(jq -r '.passed' "$_CITE_FILE" 2>/dev/null)
|
|
387
|
+
_CITE_SUGGEST=$(jq -r '.suggestion // ""' "$_CITE_FILE" 2>/dev/null)
|
|
388
|
+
if [ "$_CITE_PASSED" = "false" ] && [ -n "$_CITE_SUGGEST" ] && [ "$_CITE_SUGGEST" != "null" ]; then
|
|
389
|
+
_KB_CITE_NUDGE="[arka:suggest] ${_CITE_SUGGEST}"
|
|
390
|
+
fi
|
|
391
|
+
fi
|
|
392
|
+
rm -f "$_CITE_FILE" 2>/dev/null
|
|
393
|
+
fi
|
|
394
|
+
fi
|
|
395
|
+
|
|
373
396
|
# ─── Output ──────────────────────────────────────────────────────────────
|
|
374
397
|
_OUT_CONTEXT="${_ARKA_GREETING:-}${_SYNC_NOTICE:-}${_ROUTE_REMINDER}${_WORKFLOW_DIRECTIVE} $python_result"
|
|
375
398
|
[ -n "$_HYGIENE" ] && _OUT_CONTEXT="$_OUT_CONTEXT $_HYGIENE"
|
|
399
|
+
[ -n "$_KB_CITE_NUDGE" ] && _OUT_CONTEXT="$_OUT_CONTEXT
|
|
400
|
+
$_KB_CITE_NUDGE"
|
|
376
401
|
[ -n "$_ARKA_CONTEXT_HITS" ] && _OUT_CONTEXT="$_OUT_CONTEXT
|
|
377
402
|
$_ARKA_CONTEXT_HITS"
|
|
378
403
|
# Escape for JSON
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
"""Governance engine — Constitution, quality gates, audit trails."""
|
|
2
2
|
|
|
3
3
|
from core.governance.constitution import Constitution, load_constitution
|
|
4
|
+
from core.governance.kb_cite_check import CitationResult, check_citation
|
|
4
5
|
|
|
5
|
-
__all__ = [
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CitationResult",
|
|
8
|
+
"Constitution",
|
|
9
|
+
"check_citation",
|
|
10
|
+
"load_constitution",
|
|
11
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arkaos",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.40.0",
|
|
4
4
|
"description": "The Operating System for AI Agent Teams",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"arkaos": "
|
|
7
|
+
"arkaos": "installer/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test \"tests/installer/**/*.test.js\"",
|