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.
Files changed (167) hide show
  1. package/README.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +16 -22
  4. package/arka/skills/flow/SKILL.md +108 -234
  5. package/config/claude-agents/eduardo-copy.md +53 -0
  6. package/config/claude-agents/francisca-tech.md +52 -0
  7. package/config/claude-agents/marta-cqo.md +52 -0
  8. package/config/claude-agents/paulo-tech-lead.md +50 -0
  9. package/config/cognition/schedules.yaml +15 -0
  10. package/config/constitution.yaml +17 -21
  11. package/config/hooks/_lib/workflow-classifier.sh +1 -1
  12. package/config/hooks/post-tool-use.sh +40 -539
  13. package/config/hooks/pre-tool-use.sh +32 -281
  14. package/config/hooks/session-start.sh +8 -10
  15. package/config/hooks/stop.sh +30 -425
  16. package/config/hooks/user-prompt-submit.sh +42 -459
  17. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  18. package/core/cognition/dreaming.py +5 -0
  19. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  20. package/core/cognition/scheduler/daemon.py +10 -0
  21. package/core/forge/__init__.py +2 -0
  22. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  23. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  24. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  25. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  26. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  27. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  28. package/core/forge/orchestrator.py +50 -27
  29. package/core/forge/persistence.py +16 -0
  30. package/core/forge/renderer.py +16 -0
  31. package/core/forge/runtime_dispatcher.py +34 -19
  32. package/core/forge/schema.py +12 -0
  33. package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  35. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  36. package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
  37. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  38. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  39. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  40. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  41. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  42. package/core/governance/closing_marker_check.py +9 -5
  43. package/core/governance/compliance_telemetry.py +1 -1
  44. package/core/governance/compliance_telemetry_cli.py +1 -1
  45. package/core/governance/evidence_checks.py +421 -0
  46. package/core/governance/meta_tag_check.py +36 -0
  47. package/core/governance/qg_verdict.py +78 -0
  48. package/core/governance/review_workflow.py +34 -1
  49. package/core/governance/skill_proposer.py +2 -1
  50. package/core/hooks/__init__.py +12 -0
  51. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  52. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  53. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  54. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  55. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  56. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  57. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  58. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  59. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  60. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  61. package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
  62. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  63. package/core/hooks/_shared.py +110 -0
  64. package/core/hooks/post_tool_use.py +689 -0
  65. package/core/hooks/pre_tool_use.py +267 -0
  66. package/core/hooks/stop.py +395 -0
  67. package/core/hooks/user_prompt_submit.py +600 -0
  68. package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
  69. package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
  70. package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
  71. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  72. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  73. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  74. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  75. package/core/knowledge/embedder.py +118 -11
  76. package/core/knowledge/vector_store.py +111 -14
  77. package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  78. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  80. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  81. package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
  82. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  83. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  84. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  85. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  86. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  87. package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
  88. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  89. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  90. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  91. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  92. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  93. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  94. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  95. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  98. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  99. package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
  100. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  101. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  102. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  103. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  104. package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
  105. package/core/runtime/base.py +29 -0
  106. package/core/runtime/capabilities_cli.py +74 -0
  107. package/core/runtime/claude_code.py +8 -0
  108. package/core/runtime/codex_cli.py +205 -37
  109. package/core/runtime/cost_governor.py +184 -0
  110. package/core/runtime/cursor.py +8 -0
  111. package/core/runtime/gemini_cli.py +8 -0
  112. package/core/runtime/llm_provider.py +32 -13
  113. package/core/runtime/llm_retry.py +233 -0
  114. package/core/runtime/native_usage.py +186 -0
  115. package/core/runtime/pricing.py +9 -0
  116. package/core/runtime/registry.py +3 -0
  117. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  119. package/core/shared/test_evidence.py +34 -0
  120. package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
  121. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  122. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  123. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  124. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  125. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  126. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  127. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  128. package/core/synapse/engine.py +7 -0
  129. package/core/synapse/graph_context_layer.py +212 -0
  130. package/core/synapse/kb_cache.py +3 -0
  131. package/core/synapse/layers.py +104 -20
  132. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  133. package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
  134. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  135. package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
  136. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  137. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  138. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  139. package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
  140. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  141. package/core/workflow/enforcer.py +5 -16
  142. package/core/workflow/flow_enforcer.py +36 -11
  143. package/core/workflow/gate_checkpoint.py +149 -0
  144. package/core/workflow/research_gate.py +15 -3
  145. package/core/workflow/rules_registry.py +137 -389
  146. package/core/workflow/specialist_enforcer.py +21 -4
  147. package/dashboard/app/pages/knowledge/index.vue +4 -1
  148. package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
  149. package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
  150. package/departments/quality/SKILL.md +45 -5
  151. package/departments/quality/agents/copy-director.yaml +3 -1
  152. package/departments/quality/agents/tech-director.yaml +3 -1
  153. package/installer/adapters/claude-code.js +46 -2
  154. package/installer/config-seed.js +39 -14
  155. package/installer/doctor.js +8 -0
  156. package/installer/graphify.js +153 -0
  157. package/installer/index.js +20 -0
  158. package/installer/init.js +11 -0
  159. package/installer/update.js +19 -0
  160. package/package.json +1 -1
  161. package/pyproject.toml +1 -1
  162. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  163. package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
  164. package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
  165. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  166. package/scripts/knowledge-index.py +8 -2
  167. 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
+ )
@@ -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
  ],
@@ -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
- lines.append(f"- [[{title}]] (path: `{path}`)")
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
- def _build_note_entry(raw: str, title: str, path: str, score: float) -> dict:
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(raw, str(title), str(source), float(score_val))
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
- score = float(h.get("score", 0.0) or 0.0)
1042
- if score < self._min_similarity:
1043
- continue
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
- if len(notes) >= self._max_notes:
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
- return [
1056
- _build_note_entry(n["raw"], n["title"], n["path"], 0.0)
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=f"[kb-context:{len(notes)}]",
1168
+ tag=tag,
1085
1169
  content=block,
1086
1170
  tokens_est=len(block.split()),
1087
1171
  compute_ms=ms,
@@ -1,15 +1,16 @@
1
1
  """Workflow enforcement engine.
2
2
 
3
- Central enforcement engine that evaluates all 14 NON-NEGOTIABLE
4
- Constitution rules against tool operations. Returns violations
5
- with severity classification (BLOCK, ESCALATE, WARN).
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
- """Mandatory 13-phase flow enforcement for write-mutation tools.
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:phase:`.
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
- PHASE_RE = re.compile(r"\[arka:phase:\d+\]", re.IGNORECASE)
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(transcript_path: str, n: int) -> list[str]:
302
- """Read the last `n` assistant messages from a JSONL transcript."""
303
- path = Path(transcript_path) if transcript_path else None
304
- if path is None or not path.exists():
305
- return []
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 path.read_text(encoding="utf-8", errors="replace").splitlines():
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 phase_match:
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 = _load_last_assistant_messages(transcript_path, ASSISTANT_WINDOW)
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: