arkaos 4.0.2 → 4.1.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/README.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +16 -22
- package/arka/skills/flow/SKILL.md +108 -234
- package/config/claude-agents/eduardo-copy.md +53 -0
- package/config/claude-agents/francisca-tech.md +52 -0
- package/config/claude-agents/marta-cqo.md +52 -0
- package/config/claude-agents/paulo-tech-lead.md +50 -0
- package/config/cognition/schedules.yaml +15 -0
- package/config/constitution.yaml +17 -21
- package/config/hooks/_lib/workflow-classifier.sh +1 -1
- package/config/hooks/post-tool-use.sh +40 -539
- package/config/hooks/pre-tool-use.sh +32 -281
- package/config/hooks/session-start.sh +8 -10
- package/config/hooks/stop.sh +30 -425
- package/config/hooks/user-prompt-submit.sh +42 -459
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/dreaming.py +5 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +10 -0
- package/core/forge/__init__.py +2 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/forge/orchestrator.py +50 -27
- package/core/forge/persistence.py +16 -0
- package/core/forge/renderer.py +16 -0
- package/core/forge/runtime_dispatcher.py +34 -19
- package/core/forge/schema.py +12 -0
- package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
- package/core/governance/closing_marker_check.py +9 -5
- package/core/governance/compliance_telemetry.py +1 -1
- package/core/governance/compliance_telemetry_cli.py +1 -1
- package/core/governance/evidence_checks.py +421 -0
- package/core/governance/meta_tag_check.py +36 -0
- package/core/governance/qg_verdict.py +78 -0
- package/core/governance/review_workflow.py +34 -1
- package/core/governance/skill_proposer.py +2 -1
- package/core/hooks/__init__.py +12 -0
- package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/_shared.py +110 -0
- package/core/hooks/post_tool_use.py +689 -0
- package/core/hooks/pre_tool_use.py +267 -0
- package/core/hooks/stop.py +395 -0
- package/core/hooks/user_prompt_submit.py +600 -0
- package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
- package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/embedder.py +118 -11
- package/core/knowledge/vector_store.py +111 -14
- package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
- package/core/runtime/base.py +29 -0
- package/core/runtime/capabilities_cli.py +74 -0
- package/core/runtime/claude_code.py +8 -0
- package/core/runtime/codex_cli.py +205 -37
- package/core/runtime/cost_governor.py +184 -0
- package/core/runtime/cursor.py +8 -0
- package/core/runtime/gemini_cli.py +8 -0
- package/core/runtime/llm_provider.py +32 -13
- package/core/runtime/llm_retry.py +233 -0
- package/core/runtime/native_usage.py +186 -0
- package/core/runtime/pricing.py +9 -0
- package/core/runtime/registry.py +3 -0
- package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
- package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
- package/core/shared/test_evidence.py +34 -0
- package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +7 -0
- package/core/synapse/graph_context_layer.py +212 -0
- package/core/synapse/kb_cache.py +3 -0
- package/core/synapse/layers.py +104 -20
- package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/enforcer.py +5 -16
- package/core/workflow/flow_enforcer.py +36 -11
- package/core/workflow/gate_checkpoint.py +149 -0
- package/core/workflow/research_gate.py +15 -3
- package/core/workflow/rules_registry.py +137 -389
- package/core/workflow/specialist_enforcer.py +21 -4
- package/dashboard/app/pages/knowledge/index.vue +4 -1
- package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
- package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
- package/departments/quality/SKILL.md +45 -5
- package/departments/quality/agents/copy-director.yaml +3 -1
- package/departments/quality/agents/tech-director.yaml +3 -1
- package/installer/adapters/claude-code.js +46 -2
- package/installer/config-seed.js +39 -14
- package/installer/doctor.js +8 -0
- package/installer/graphify.js +153 -0
- package/installer/index.js +20 -0
- package/installer/init.js +11 -0
- package/installer/update.js +19 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
- package/scripts/knowledge-index.py +8 -2
- package/scripts/synapse-bridge.py +102 -26
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Synapse layer L2.7 — Graphify grounding context injection.
|
|
2
|
+
|
|
3
|
+
When the working project has a Graphify code knowledge graph
|
|
4
|
+
(``graphify-out/graph.json``, generated locally by the ``graphify`` CLI
|
|
5
|
+
via tree-sitter), this layer matches the user's prompt keywords against
|
|
6
|
+
graph node labels and injects the top matches as grounded context:
|
|
7
|
+
node label, confidence tag and the exact ``source_location``. The model
|
|
8
|
+
then cites real code structure instead of inventing it.
|
|
9
|
+
|
|
10
|
+
Honesty contract (PR-3 v4.1 — Graphify grounding):
|
|
11
|
+
- Only ``EXTRACTED`` nodes are injected as-is. ``INFERRED`` nodes are
|
|
12
|
+
included with an explicit ``(inferred)`` suffix. ``AMBIGUOUS`` nodes
|
|
13
|
+
are never injected.
|
|
14
|
+
- ``source_location`` is NEVER truncated — a clipped citation is worse
|
|
15
|
+
than no citation.
|
|
16
|
+
- No graph → the layer contributes nothing (inert, zero tokens).
|
|
17
|
+
|
|
18
|
+
Feature flag: ``synapse.graphContext`` in ``~/.arkaos/config.json``
|
|
19
|
+
(default ``true`` — mirrors how L2.5 reads ``synapse.l25KbContext``).
|
|
20
|
+
``ARKA_BYPASS_L27=1`` env disables for debugging.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
from core.synapse.layers import Layer, LayerResult, PromptContext
|
|
32
|
+
from core.synapse.pattern_library_layer import _extract_keywords
|
|
33
|
+
|
|
34
|
+
# graph.json can grow to many MB on large repos. Loading it inside a
|
|
35
|
+
# per-prompt hook with a <100ms Synapse budget would blow both latency
|
|
36
|
+
# and memory, so beyond this cap we skip with an explicit metrics tag
|
|
37
|
+
# instead of parsing. (`graphify . --update` keeps graphs incremental;
|
|
38
|
+
# a >10MB graph is a signal the project should shard extraction, not
|
|
39
|
+
# that the hook should pay for it.)
|
|
40
|
+
_MAX_GRAPH_BYTES = 10 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
_MAX_NODES = 5
|
|
43
|
+
_MAX_PARENT_WALK = 3 # cwd + up to 3 parent dirs
|
|
44
|
+
_MAX_TOKENS_EST = 400 # ~4 chars/token budget cap for the whole block
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _graph_flag_on() -> bool:
|
|
48
|
+
if os.environ.get("ARKA_BYPASS_L27", "").strip() == "1":
|
|
49
|
+
return False
|
|
50
|
+
config_path = Path.home() / ".arkaos" / "config.json"
|
|
51
|
+
if not config_path.exists():
|
|
52
|
+
return True
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return True
|
|
57
|
+
synapse_cfg = data.get("synapse") or {}
|
|
58
|
+
return bool(synapse_cfg.get("graphContext", True))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _locate_graph(cwd: str) -> Optional[Path]:
|
|
62
|
+
"""Find ``graphify-out/graph.json`` in cwd or up to 3 parent dirs."""
|
|
63
|
+
if not cwd:
|
|
64
|
+
return None
|
|
65
|
+
current = Path(cwd)
|
|
66
|
+
if not current.is_dir():
|
|
67
|
+
return None
|
|
68
|
+
for candidate in [current, *current.parents[:_MAX_PARENT_WALK]]:
|
|
69
|
+
graph = candidate / "graphify-out" / "graph.json"
|
|
70
|
+
if graph.is_file():
|
|
71
|
+
return graph
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _node_degrees(edges: list) -> dict[str, int]:
|
|
76
|
+
degrees: dict[str, int] = {}
|
|
77
|
+
for edge in edges:
|
|
78
|
+
if not isinstance(edge, dict):
|
|
79
|
+
continue
|
|
80
|
+
for key in ("source", "target"):
|
|
81
|
+
node_id = edge.get(key)
|
|
82
|
+
if isinstance(node_id, str) and node_id:
|
|
83
|
+
degrees[node_id] = degrees.get(node_id, 0) + 1
|
|
84
|
+
return degrees
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _score_nodes(
|
|
88
|
+
nodes: list, keywords: list[str], degrees: dict[str, int]
|
|
89
|
+
) -> list[tuple[int, int, dict]]:
|
|
90
|
+
"""Rank nodes by keyword-match count, then degree. Drops non-matches."""
|
|
91
|
+
scored: list[tuple[int, int, dict]] = []
|
|
92
|
+
for node in nodes:
|
|
93
|
+
if not isinstance(node, dict):
|
|
94
|
+
continue
|
|
95
|
+
confidence = str(node.get("confidence", "")).upper()
|
|
96
|
+
if confidence not in ("EXTRACTED", "INFERRED"):
|
|
97
|
+
continue # AMBIGUOUS (or untagged) nodes are never injected
|
|
98
|
+
label = str(node.get("label") or node.get("id") or "").strip()
|
|
99
|
+
if not label:
|
|
100
|
+
continue
|
|
101
|
+
haystack = label.lower()
|
|
102
|
+
matches = sum(1 for kw in keywords if kw in haystack)
|
|
103
|
+
if matches == 0:
|
|
104
|
+
continue
|
|
105
|
+
degree = degrees.get(str(node.get("id", "")), 0)
|
|
106
|
+
scored.append((matches, degree, node))
|
|
107
|
+
scored.sort(key=lambda row: (-row[0], -row[1]))
|
|
108
|
+
return scored
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_node_line(node: dict) -> str:
|
|
112
|
+
confidence = str(node.get("confidence", "")).upper()
|
|
113
|
+
label = str(node.get("label") or node.get("id") or "").strip()
|
|
114
|
+
# source_location is the citation — NEVER truncate it.
|
|
115
|
+
location = str(node.get("source_location", "")).strip() or "(no source_location)"
|
|
116
|
+
line = f"- {label} [{confidence}] — {location}"
|
|
117
|
+
if confidence == "INFERRED":
|
|
118
|
+
line += " (inferred)"
|
|
119
|
+
return line
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_graph_block(lines: list[str]) -> str:
|
|
123
|
+
header = (
|
|
124
|
+
f"[arka:graph-context:{len(lines)}] Graphify grounded nodes for this "
|
|
125
|
+
f"prompt (cite source_location; EXTRACTED = verified from code):"
|
|
126
|
+
)
|
|
127
|
+
return "\n".join([header, *lines])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class GraphContextLayer(Layer):
|
|
131
|
+
"""L2.7 — inject Graphify graph nodes matching the user prompt."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, max_nodes: int = _MAX_NODES) -> None:
|
|
134
|
+
self._max_nodes = max_nodes
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def id(self) -> str:
|
|
138
|
+
return "L2.7"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def name(self) -> str:
|
|
142
|
+
return "GraphContext"
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def cache_ttl(self) -> int:
|
|
146
|
+
return 30
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def priority(self) -> int:
|
|
150
|
+
return 26 # right after L2.5 KBContext (25), before ProjectLayer (30)
|
|
151
|
+
|
|
152
|
+
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
153
|
+
start = time.time()
|
|
154
|
+
if not ctx.user_input or not _graph_flag_on():
|
|
155
|
+
return self._empty(start)
|
|
156
|
+
graph_path = _locate_graph(ctx.cwd)
|
|
157
|
+
if graph_path is None:
|
|
158
|
+
return self._empty(start)
|
|
159
|
+
try:
|
|
160
|
+
if graph_path.stat().st_size > _MAX_GRAPH_BYTES:
|
|
161
|
+
# See _MAX_GRAPH_BYTES: too big to parse inside the hook
|
|
162
|
+
# budget — surface a metrics note instead of silence.
|
|
163
|
+
return self._empty(start, tag="[graph-context:skipped size>10MB]")
|
|
164
|
+
data = json.loads(graph_path.read_text(encoding="utf-8"))
|
|
165
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
166
|
+
return self._empty(start)
|
|
167
|
+
return self._build_result(ctx, data, start)
|
|
168
|
+
|
|
169
|
+
def _build_result(self, ctx: PromptContext, data: Any, start: float) -> LayerResult:
|
|
170
|
+
if not isinstance(data, dict):
|
|
171
|
+
return self._empty(start)
|
|
172
|
+
keywords = _extract_keywords(ctx.user_input)
|
|
173
|
+
if not keywords:
|
|
174
|
+
return self._empty(start)
|
|
175
|
+
nodes = data.get("nodes") or []
|
|
176
|
+
edges = data.get("edges") or []
|
|
177
|
+
scored = _score_nodes(nodes, keywords, _node_degrees(edges))
|
|
178
|
+
lines = self._capped_lines(scored)
|
|
179
|
+
if not lines:
|
|
180
|
+
return self._empty(start)
|
|
181
|
+
content = _format_graph_block(lines)
|
|
182
|
+
return LayerResult(
|
|
183
|
+
layer_id=self.id,
|
|
184
|
+
tag=f"[graph-context:{len(lines)}]",
|
|
185
|
+
content=content,
|
|
186
|
+
tokens_est=max(1, len(content) // 4),
|
|
187
|
+
compute_ms=int((time.time() - start) * 1000),
|
|
188
|
+
cached=False,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _capped_lines(self, scored: list[tuple[int, int, dict]]) -> list[str]:
|
|
192
|
+
"""Top-N node lines, kept under the ~400-token block budget."""
|
|
193
|
+
lines: list[str] = []
|
|
194
|
+
budget_chars = _MAX_TOKENS_EST * 4
|
|
195
|
+
used = 0
|
|
196
|
+
for _, _, node in scored[: self._max_nodes]:
|
|
197
|
+
line = _format_node_line(node)
|
|
198
|
+
if lines and used + len(line) > budget_chars:
|
|
199
|
+
break
|
|
200
|
+
lines.append(line)
|
|
201
|
+
used += len(line)
|
|
202
|
+
return lines
|
|
203
|
+
|
|
204
|
+
def _empty(self, start: float, tag: str = "") -> LayerResult:
|
|
205
|
+
return LayerResult(
|
|
206
|
+
layer_id=self.id,
|
|
207
|
+
tag=tag,
|
|
208
|
+
content="",
|
|
209
|
+
tokens_est=0,
|
|
210
|
+
compute_ms=int((time.time() - start) * 1000),
|
|
211
|
+
cached=False,
|
|
212
|
+
)
|
package/core/synapse/kb_cache.py
CHANGED
|
@@ -248,7 +248,10 @@ class KBSessionCache:
|
|
|
248
248
|
"text": r.get("text", "")[:300],
|
|
249
249
|
"source": r.get("source", ""),
|
|
250
250
|
"heading": r.get("heading", ""),
|
|
251
|
+
# None = keyword-degraded hit (no similarity exists);
|
|
252
|
+
# preserved as-is so cached entries stay honest.
|
|
251
253
|
"score": r.get("score", 0.0),
|
|
254
|
+
"retrieval": r.get("retrieval", ""),
|
|
252
255
|
}
|
|
253
256
|
for r in results
|
|
254
257
|
],
|
package/core/synapse/layers.py
CHANGED
|
@@ -661,7 +661,12 @@ class KnowledgeRetrievalLayer(Layer):
|
|
|
661
661
|
|
|
662
662
|
content = " | ".join(parts)
|
|
663
663
|
chunk_count = len(snippets) + (len(overlapping) if overlapping else 0)
|
|
664
|
+
# RAG honesty (PR-3 v4.1): keyword-degraded results must never be
|
|
665
|
+
# presented as semantic similarity — label the tag explicitly.
|
|
666
|
+
degraded = any(r.get("retrieval") == "keyword-degraded" for r in results)
|
|
664
667
|
tag = f"[knowledge:{chunk_count} chunks]"
|
|
668
|
+
if degraded:
|
|
669
|
+
tag = f"[knowledge:{chunk_count} chunks degraded=keyword]"
|
|
665
670
|
ms = int((time.time() - start) * 1000)
|
|
666
671
|
tokens_est = len(content.split())
|
|
667
672
|
|
|
@@ -879,19 +884,27 @@ def _extract_wikilinks(raw: str, limit: int = 3) -> list[str]:
|
|
|
879
884
|
return seen
|
|
880
885
|
|
|
881
886
|
|
|
882
|
-
def _format_kb_block(notes: list[dict]) -> str:
|
|
887
|
+
def _format_kb_block(notes: list[dict], degraded: bool = False) -> str:
|
|
883
888
|
lines: list[str] = [
|
|
884
889
|
f"[arka:kb-context] O teu cérebro (Obsidian) tem {len(notes)} "
|
|
885
890
|
f"nota{'s' if len(notes) != 1 else ''} relevante{'s' if len(notes) != 1 else ''} "
|
|
886
891
|
f"para este pedido:",
|
|
887
892
|
"",
|
|
888
893
|
]
|
|
894
|
+
if degraded:
|
|
895
|
+
lines.insert(1, "")
|
|
896
|
+
lines.insert(
|
|
897
|
+
1,
|
|
898
|
+
"Atenção: correspondência por palavras-chave (pesquisa semântica "
|
|
899
|
+
"indisponível) — NÃO é similaridade semântica.",
|
|
900
|
+
)
|
|
889
901
|
for note in notes:
|
|
890
902
|
title = note.get("title", "")
|
|
891
903
|
path = note.get("path", "")
|
|
892
904
|
excerpt = note.get("excerpt", "")
|
|
893
905
|
relates = note.get("relates", []) or []
|
|
894
|
-
|
|
906
|
+
suffix = " (inferred — not authoritative)" if note.get("inferred") else ""
|
|
907
|
+
lines.append(f"- [[{title}]]{suffix} (path: `{path}`)")
|
|
895
908
|
if excerpt:
|
|
896
909
|
lines.append(f" Excerto: {excerpt}")
|
|
897
910
|
if relates:
|
|
@@ -950,13 +963,53 @@ def _load_fallback_notes(vault_path: Optional[Path]) -> list[dict]:
|
|
|
950
963
|
return notes
|
|
951
964
|
|
|
952
965
|
|
|
953
|
-
|
|
966
|
+
_GROUNDING_INFERRED_RE = re.compile(r"^grounding:\s*inferred\s*$", re.MULTILINE)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _frontmatter_marks_inferred(raw: str) -> bool:
|
|
970
|
+
"""Cheap check: does the YAML frontmatter carry `grounding: inferred`?
|
|
971
|
+
|
|
972
|
+
Parses ONLY the frontmatter block (the note content is already in
|
|
973
|
+
hand) — no YAML library, no full-document scan. Dreaming-written
|
|
974
|
+
notes carry this marker (see core/cognition/dreaming.py, PR-3 v4.1).
|
|
975
|
+
"""
|
|
976
|
+
match = _FRONTMATTER_RE.match(raw or "")
|
|
977
|
+
if not match:
|
|
978
|
+
return False
|
|
979
|
+
return bool(_GROUNDING_INFERRED_RE.search(match.group(0)))
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _hit_is_inferred(hit: dict) -> bool:
|
|
983
|
+
"""Inferred check for a vector-store hit.
|
|
984
|
+
|
|
985
|
+
Chunk text has frontmatter stripped by the chunker, so check the hit
|
|
986
|
+
metadata first, then read just the head of the source file (cheap:
|
|
987
|
+
frontmatter lives in the first bytes).
|
|
988
|
+
"""
|
|
989
|
+
metadata = hit.get("metadata") or {}
|
|
990
|
+
if isinstance(metadata, dict) and metadata.get("grounding") == "inferred":
|
|
991
|
+
return True
|
|
992
|
+
source = hit.get("source", "") or ""
|
|
993
|
+
if not source:
|
|
994
|
+
return False
|
|
995
|
+
try:
|
|
996
|
+
with open(source, "r", encoding="utf-8", errors="ignore") as fh:
|
|
997
|
+
head = fh.read(2048)
|
|
998
|
+
except OSError:
|
|
999
|
+
return False
|
|
1000
|
+
return _frontmatter_marks_inferred(head)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _build_note_entry(
|
|
1004
|
+
raw: str, title: str, path: str, score: float, inferred: bool = False
|
|
1005
|
+
) -> dict:
|
|
954
1006
|
return {
|
|
955
1007
|
"title": title,
|
|
956
1008
|
"path": path,
|
|
957
1009
|
"excerpt": _extract_excerpt(raw),
|
|
958
1010
|
"relates": _extract_wikilinks(raw),
|
|
959
1011
|
"score": float(score),
|
|
1012
|
+
"inferred": inferred,
|
|
960
1013
|
}
|
|
961
1014
|
|
|
962
1015
|
|
|
@@ -965,7 +1018,24 @@ def _note_from_vector_hit(hit: dict) -> dict:
|
|
|
965
1018
|
raw = hit.get("text", "") or ""
|
|
966
1019
|
title = hit.get("heading") or Path(source).stem or "note"
|
|
967
1020
|
score_val = hit.get("score", 0.0) or 0.0
|
|
968
|
-
return _build_note_entry(
|
|
1021
|
+
return _build_note_entry(
|
|
1022
|
+
raw, str(title), str(source), float(score_val),
|
|
1023
|
+
inferred=_hit_is_inferred(hit),
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _apply_grounding_policy(notes: list[dict], max_notes: int) -> list[dict]:
|
|
1028
|
+
"""Quarantine inferred notes (Dreaming output) from grounded context.
|
|
1029
|
+
|
|
1030
|
+
Policy (PR-3 v4.1): inferred notes are EXCLUDED by default; they are
|
|
1031
|
+
only included — explicitly suffixed `(inferred — not authoritative)`
|
|
1032
|
+
by the formatter — when fewer than 2 grounded notes matched.
|
|
1033
|
+
"""
|
|
1034
|
+
grounded = [n for n in notes if not n.get("inferred")]
|
|
1035
|
+
if len(grounded) >= 2:
|
|
1036
|
+
return grounded[:max_notes]
|
|
1037
|
+
inferred = [n for n in notes if n.get("inferred")]
|
|
1038
|
+
return (grounded + inferred)[:max_notes]
|
|
969
1039
|
|
|
970
1040
|
|
|
971
1041
|
class KBContextLayer(Layer):
|
|
@@ -1034,54 +1104,68 @@ class KBContextLayer(Layer):
|
|
|
1034
1104
|
except Exception:
|
|
1035
1105
|
pass
|
|
1036
1106
|
|
|
1037
|
-
def _retrieve(self, prompt: str) -> list[dict]:
|
|
1107
|
+
def _retrieve(self, prompt: str) -> tuple[list[dict], bool]:
|
|
1108
|
+
"""Return (notes, degraded). Degraded = keyword-only retrieval.
|
|
1109
|
+
|
|
1110
|
+
Degraded hits carry no similarity score, so the min_similarity
|
|
1111
|
+
threshold does not apply to them — they are included but labeled
|
|
1112
|
+
(never presented as semantic matches).
|
|
1113
|
+
"""
|
|
1038
1114
|
hits = _vector_search(self._store, prompt, top_k=self._max_notes * 2)
|
|
1115
|
+
degraded = any(h.get("retrieval") == "keyword-degraded" for h in hits)
|
|
1039
1116
|
notes: list[dict] = []
|
|
1040
1117
|
for h in hits:
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1118
|
+
if not degraded:
|
|
1119
|
+
score = float(h.get("score", 0.0) or 0.0)
|
|
1120
|
+
if score < self._min_similarity:
|
|
1121
|
+
continue
|
|
1044
1122
|
notes.append(_note_from_vector_hit(h))
|
|
1045
|
-
|
|
1046
|
-
break
|
|
1123
|
+
notes = _apply_grounding_policy(notes, self._max_notes)
|
|
1047
1124
|
if notes:
|
|
1048
|
-
return notes
|
|
1125
|
+
return notes, degraded
|
|
1049
1126
|
candidates = _load_fallback_notes(self._vault_path)
|
|
1050
1127
|
if not candidates:
|
|
1051
|
-
return []
|
|
1128
|
+
return [], False
|
|
1052
1129
|
picked = _jaccard_fallback(
|
|
1053
|
-
prompt, candidates, self._max_notes, self._min_similarity
|
|
1130
|
+
prompt, candidates, self._max_notes * 2, self._min_similarity
|
|
1054
1131
|
)
|
|
1055
|
-
|
|
1056
|
-
_build_note_entry(
|
|
1132
|
+
fallback_notes = [
|
|
1133
|
+
_build_note_entry(
|
|
1134
|
+
n["raw"], n["title"], n["path"], 0.0,
|
|
1135
|
+
inferred=_frontmatter_marks_inferred(n["raw"]),
|
|
1136
|
+
)
|
|
1057
1137
|
for n in picked
|
|
1058
1138
|
]
|
|
1139
|
+
return _apply_grounding_policy(fallback_notes, self._max_notes), False
|
|
1059
1140
|
|
|
1060
1141
|
def build(self, prompt: str) -> Optional[str]:
|
|
1061
1142
|
"""Public entrypoint — returns the formatted block or None."""
|
|
1062
1143
|
if not prompt or not _l25_feature_flag_on():
|
|
1063
1144
|
return None
|
|
1064
|
-
notes = self._retrieve(prompt[:2000])
|
|
1145
|
+
notes, degraded = self._retrieve(prompt[:2000])
|
|
1065
1146
|
if not notes:
|
|
1066
1147
|
return None
|
|
1067
|
-
return _format_kb_block(notes[: self._max_notes])
|
|
1148
|
+
return _format_kb_block(notes[: self._max_notes], degraded=degraded)
|
|
1068
1149
|
|
|
1069
1150
|
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
1070
1151
|
start = time.time()
|
|
1071
1152
|
if not ctx.user_input or not _l25_feature_flag_on():
|
|
1072
1153
|
return self._empty(start)
|
|
1073
1154
|
try:
|
|
1074
|
-
notes = self._retrieve(ctx.user_input[:2000])
|
|
1155
|
+
notes, degraded = self._retrieve(ctx.user_input[:2000])
|
|
1075
1156
|
except Exception:
|
|
1076
1157
|
return self._empty(start)
|
|
1077
1158
|
self._record(ctx, len(notes))
|
|
1078
1159
|
if not notes:
|
|
1079
1160
|
return self._empty(start)
|
|
1080
|
-
block = _format_kb_block(notes[: self._max_notes])
|
|
1161
|
+
block = _format_kb_block(notes[: self._max_notes], degraded=degraded)
|
|
1081
1162
|
ms = int((time.time() - start) * 1000)
|
|
1163
|
+
tag = f"[kb-context:{len(notes)}]"
|
|
1164
|
+
if degraded:
|
|
1165
|
+
tag = f"[kb-context:{len(notes)} degraded=keyword]"
|
|
1082
1166
|
return LayerResult(
|
|
1083
1167
|
layer_id=self.id,
|
|
1084
|
-
tag=
|
|
1168
|
+
tag=tag,
|
|
1085
1169
|
content=block,
|
|
1086
1170
|
tokens_est=len(block.split()),
|
|
1087
1171
|
compute_ms=ms,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"""Workflow enforcement engine.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Evaluates the disk-verifiable Constitution rules in
|
|
4
|
+
``core.workflow.rules_registry`` against tool operations. Only rules
|
|
5
|
+
whose state can be read from disk live in that registry; behavioral
|
|
6
|
+
rules are enforced by hooks/telemetry (see the registry docstring).
|
|
7
|
+
Returns violations with severity classification (BLOCK, ESCALATE, WARN).
|
|
6
8
|
|
|
7
9
|
BLOCK severity = operation is halted until resolved
|
|
8
10
|
ESCALATE severity = operation continues but alerts Tier 0
|
|
9
11
|
WARN severity = non-blocking advisory
|
|
10
12
|
"""
|
|
11
13
|
|
|
12
|
-
import re
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
from core.workflow.rules_registry import RULES_REGISTRY, RuleDefinition
|
|
@@ -94,18 +95,6 @@ def _build_context(
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
def _is_code_file(file_path: str) -> bool:
|
|
98
|
-
"""Check if file is a code file subject to enforcement."""
|
|
99
|
-
code_extensions = {".py", ".js", ".ts", ".vue", ".php", ".jsx", ".tsx"}
|
|
100
|
-
return any(file_path.endswith(ext) for ext in code_extensions)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def _is_text_file(file_path: str) -> bool:
|
|
104
|
-
"""Check if file is a text file subject to human-writing checks."""
|
|
105
|
-
text_extensions = {".md", ".txt", ".json", ".yml", ".yaml"}
|
|
106
|
-
return any(file_path.endswith(ext) for ext in text_extensions)
|
|
107
|
-
|
|
108
|
-
|
|
109
98
|
def enforce(tool_name: str, context: dict[str, Any]) -> EnforcementResult:
|
|
110
99
|
"""Evaluate all applicable rules against the given context.
|
|
111
100
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Evidence-flow enforcement for write-mutation tools.
|
|
2
2
|
|
|
3
3
|
Invoked by the Claude Code `PreToolUse` hook. Decides whether a `Write`,
|
|
4
4
|
`Edit`, or `MultiEdit` tool call may proceed, based on markers observed
|
|
@@ -8,7 +8,9 @@ Design contract:
|
|
|
8
8
|
- Stateless transcript parse (no /tmp state for decisions).
|
|
9
9
|
- Side effects limited to reading the transcript path supplied by the hook.
|
|
10
10
|
- Signals permission when the assistant has emitted one of the flow markers:
|
|
11
|
-
`[arka:routing]`, `[arka:trivial]`, or `[arka:
|
|
11
|
+
`[arka:routing]`, `[arka:trivial]`, or `[arka:gate:` (v4.1 evidence flow).
|
|
12
|
+
The legacy 13-phase `[arka:phase:` marker remains accepted during the
|
|
13
|
+
v4.1 deprecation window.
|
|
12
14
|
- Respects `ARKA_BYPASS_FLOW=1` env var (installer/`/arka update` internal).
|
|
13
15
|
- Honors feature flag `hooks.hardEnforcement` in `~/.arkaos/config.json`.
|
|
14
16
|
- Gated tool list is closed: anything outside it is always allowed.
|
|
@@ -186,7 +188,9 @@ def bash_is_effect(command: str) -> bool:
|
|
|
186
188
|
|
|
187
189
|
ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
|
|
188
190
|
TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
|
|
189
|
-
|
|
191
|
+
GATE_RE = re.compile(r"\[arka:gate:[1-4]\]", re.IGNORECASE)
|
|
192
|
+
# Legacy 13-phase marker — accepted during the v4.1 deprecation window.
|
|
193
|
+
PHASE_RE = re.compile(r"\[arka:phase:\d+(\.\d+)?\]", re.IGNORECASE)
|
|
190
194
|
|
|
191
195
|
# Re-export for backward compatibility with any external importers that
|
|
192
196
|
# relied on the module-level symbols before the core.shared extraction.
|
|
@@ -298,13 +302,21 @@ def _extract_text(content: object) -> str:
|
|
|
298
302
|
return ""
|
|
299
303
|
|
|
300
304
|
|
|
301
|
-
def _load_last_assistant_messages(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
def _load_last_assistant_messages(
|
|
306
|
+
transcript_path: str, n: int, raw_text: str | None = None
|
|
307
|
+
) -> list[str]:
|
|
308
|
+
"""Read the last `n` assistant messages from a JSONL transcript.
|
|
309
|
+
|
|
310
|
+
``raw_text`` lets callers that already read the transcript (PR-6 hook
|
|
311
|
+
consolidation — parse once per hook invocation) skip the disk read.
|
|
312
|
+
"""
|
|
313
|
+
if raw_text is None:
|
|
314
|
+
path = Path(transcript_path) if transcript_path else None
|
|
315
|
+
if path is None or not path.exists():
|
|
316
|
+
return []
|
|
317
|
+
raw_text = path.read_text(encoding="utf-8", errors="replace")
|
|
306
318
|
messages: list[str] = []
|
|
307
|
-
for line in
|
|
319
|
+
for line in raw_text.splitlines():
|
|
308
320
|
if not line.strip():
|
|
309
321
|
continue
|
|
310
322
|
try:
|
|
@@ -329,14 +341,19 @@ def _scan_markers(messages: list[str]) -> tuple[str | None, str | None]:
|
|
|
329
341
|
phase_observed: str | None = None
|
|
330
342
|
for text in messages:
|
|
331
343
|
if phase_observed is None:
|
|
344
|
+
gate_match = GATE_RE.search(text)
|
|
332
345
|
phase_match = PHASE_RE.search(text)
|
|
333
|
-
if
|
|
346
|
+
if gate_match:
|
|
347
|
+
phase_observed = gate_match.group(0)
|
|
348
|
+
elif phase_match:
|
|
334
349
|
phase_observed = phase_match.group(0)
|
|
335
350
|
if marker_found is None:
|
|
336
351
|
if ROUTING_RE.search(text):
|
|
337
352
|
marker_found = "routing"
|
|
338
353
|
elif TRIVIAL_RE.search(text):
|
|
339
354
|
marker_found = "trivial"
|
|
355
|
+
elif GATE_RE.search(text):
|
|
356
|
+
marker_found = "gate"
|
|
340
357
|
elif PHASE_RE.search(text):
|
|
341
358
|
marker_found = "phase"
|
|
342
359
|
return marker_found, phase_observed
|
|
@@ -348,6 +365,7 @@ def evaluate(
|
|
|
348
365
|
session_id: str = "",
|
|
349
366
|
cwd: str = "",
|
|
350
367
|
tool_input: dict | None = None,
|
|
368
|
+
messages: list[str] | None = None,
|
|
351
369
|
) -> Decision:
|
|
352
370
|
"""Decide whether a tool call may proceed.
|
|
353
371
|
|
|
@@ -357,6 +375,10 @@ def evaluate(
|
|
|
357
375
|
PR11 v2.33.0 expanded the gated set beyond Write/Edit/MultiEdit to
|
|
358
376
|
cover all EFFECT tools (NotebookEdit, Task, Skill) and to classify
|
|
359
377
|
Bash commands per-command via ``bash_is_effect``.
|
|
378
|
+
|
|
379
|
+
``messages`` (PR-6 hook consolidation): pre-parsed assistant messages.
|
|
380
|
+
When None (default) the transcript is read from ``transcript_path`` —
|
|
381
|
+
backward compatible with all existing callers.
|
|
360
382
|
"""
|
|
361
383
|
is_gated = tool_name in EFFECT_TOOLS_ALWAYS
|
|
362
384
|
if not is_gated and tool_name == "Bash":
|
|
@@ -386,7 +408,10 @@ def evaluate(
|
|
|
386
408
|
marker_found=cached_type or None,
|
|
387
409
|
)
|
|
388
410
|
|
|
389
|
-
messages
|
|
411
|
+
if messages is None:
|
|
412
|
+
messages = _load_last_assistant_messages(
|
|
413
|
+
transcript_path, ASSISTANT_WINDOW
|
|
414
|
+
)
|
|
390
415
|
marker_found, phase_observed = _scan_markers(messages)
|
|
391
416
|
|
|
392
417
|
if marker_found is None:
|