arkaos 4.0.2 → 4.1.1

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 (214) 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 +52 -536
  13. package/config/hooks/pre-tool-use.sh +46 -280
  14. package/config/hooks/session-start.sh +8 -10
  15. package/config/hooks/stop.sh +44 -424
  16. package/config/hooks/user-prompt-submit.sh +55 -457
  17. package/core/__pycache__/__init__.cpython-314.pyc +0 -0
  18. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  19. package/core/cognition/dreaming.py +5 -0
  20. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  21. package/core/cognition/scheduler/daemon.py +10 -0
  22. package/core/forge/__init__.py +2 -0
  23. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  25. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  26. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  27. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  28. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  29. package/core/forge/orchestrator.py +50 -27
  30. package/core/forge/persistence.py +16 -0
  31. package/core/forge/renderer.py +16 -0
  32. package/core/forge/runtime_dispatcher.py +34 -19
  33. package/core/forge/schema.py +12 -0
  34. package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
  35. package/core/governance/__pycache__/__init__.cpython-314.pyc +0 -0
  36. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  37. package/core/governance/__pycache__/closing_marker_check.cpython-314.pyc +0 -0
  38. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  39. package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
  40. package/core/governance/__pycache__/constitution.cpython-314.pyc +0 -0
  41. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  42. package/core/governance/__pycache__/kb_cite_check.cpython-314.pyc +0 -0
  43. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  44. package/core/governance/__pycache__/meta_tag_check.cpython-314.pyc +0 -0
  45. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  46. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  47. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  48. package/core/governance/__pycache__/skill_proposer.cpython-314.pyc +0 -0
  49. package/core/governance/__pycache__/sycophancy_detector.cpython-314.pyc +0 -0
  50. package/core/governance/closing_marker_check.py +9 -5
  51. package/core/governance/compliance_telemetry.py +1 -1
  52. package/core/governance/compliance_telemetry_cli.py +1 -1
  53. package/core/governance/evidence_checks.py +421 -0
  54. package/core/governance/meta_tag_check.py +36 -0
  55. package/core/governance/qg_verdict.py +78 -0
  56. package/core/governance/review_workflow.py +34 -1
  57. package/core/governance/skill_proposer.py +2 -1
  58. package/core/hooks/__init__.py +12 -0
  59. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  60. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  61. package/core/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  62. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  63. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  64. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  65. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  66. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  67. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  68. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  69. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  70. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  71. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  72. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  73. package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
  74. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  75. package/core/hooks/_shared.py +110 -0
  76. package/core/hooks/post_tool_use.py +689 -0
  77. package/core/hooks/pre_tool_use.py +267 -0
  78. package/core/hooks/stop.py +395 -0
  79. package/core/hooks/user_prompt_submit.py +600 -0
  80. package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
  81. package/core/jobs/__pycache__/__init__.cpython-314.pyc +0 -0
  82. package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
  83. package/core/jobs/__pycache__/auto_doc_worker.cpython-314.pyc +0 -0
  84. package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
  85. package/core/jobs/__pycache__/manager.cpython-314.pyc +0 -0
  86. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  87. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  88. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  89. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  90. package/core/knowledge/embedder.py +118 -11
  91. package/core/knowledge/vector_store.py +111 -14
  92. package/core/memory/__pycache__/session_store.cpython-314.pyc +0 -0
  93. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  94. package/core/obsidian/relator.py +18 -8
  95. package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/__init__.cpython-314.pyc +0 -0
  98. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  99. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  100. package/core/runtime/__pycache__/base.cpython-314.pyc +0 -0
  101. package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
  102. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  103. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  104. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  105. package/core/runtime/__pycache__/claude_code.cpython-314.pyc +0 -0
  106. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  107. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  108. package/core/runtime/__pycache__/codex_cli.cpython-314.pyc +0 -0
  109. package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
  110. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  111. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  112. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  113. package/core/runtime/__pycache__/cursor.cpython-314.pyc +0 -0
  114. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  115. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  116. package/core/runtime/__pycache__/gemini_cli.cpython-314.pyc +0 -0
  117. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-314.pyc +0 -0
  118. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  119. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  120. package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
  121. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  122. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  123. package/core/runtime/__pycache__/llm_retry.cpython-314.pyc +0 -0
  124. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  125. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  126. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  127. package/core/runtime/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  128. package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
  129. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  130. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  131. package/core/runtime/__pycache__/pricing.cpython-314.pyc +0 -0
  132. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  133. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  134. package/core/runtime/__pycache__/registry.cpython-314.pyc +0 -0
  135. package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
  136. package/core/runtime/base.py +29 -0
  137. package/core/runtime/capabilities_cli.py +74 -0
  138. package/core/runtime/claude_code.py +8 -0
  139. package/core/runtime/codex_cli.py +205 -37
  140. package/core/runtime/cost_governor.py +184 -0
  141. package/core/runtime/cursor.py +8 -0
  142. package/core/runtime/gemini_cli.py +8 -0
  143. package/core/runtime/llm_provider.py +32 -13
  144. package/core/runtime/llm_retry.py +233 -0
  145. package/core/runtime/native_usage.py +186 -0
  146. package/core/runtime/pricing.py +9 -0
  147. package/core/runtime/registry.py +3 -0
  148. package/core/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  149. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  150. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  152. package/core/shared/test_evidence.py +34 -0
  153. package/core/synapse/__pycache__/__init__.cpython-314.pyc +0 -0
  154. package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
  155. package/core/synapse/__pycache__/cache.cpython-314.pyc +0 -0
  156. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  157. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  158. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  159. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  160. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  161. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  162. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  163. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  164. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  165. package/core/synapse/__pycache__/layers.cpython-314.pyc +0 -0
  166. package/core/synapse/engine.py +7 -0
  167. package/core/synapse/graph_context_layer.py +212 -0
  168. package/core/synapse/kb_cache.py +3 -0
  169. package/core/synapse/layers.py +104 -20
  170. package/core/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  171. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  172. package/core/workflow/__pycache__/engine.cpython-314.pyc +0 -0
  173. package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
  174. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  175. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  176. package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
  177. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  178. package/core/workflow/__pycache__/gate_checkpoint.cpython-314.pyc +0 -0
  179. package/core/workflow/__pycache__/loader.cpython-314.pyc +0 -0
  180. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  181. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  182. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  183. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  184. package/core/workflow/__pycache__/schema.cpython-314.pyc +0 -0
  185. package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
  186. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  187. package/core/workflow/__pycache__/state.cpython-314.pyc +0 -0
  188. package/core/workflow/enforcer.py +5 -16
  189. package/core/workflow/flow_enforcer.py +36 -11
  190. package/core/workflow/gate_checkpoint.py +149 -0
  191. package/core/workflow/research_gate.py +15 -3
  192. package/core/workflow/rules_registry.py +137 -389
  193. package/core/workflow/specialist_enforcer.py +21 -4
  194. package/dashboard/app/pages/knowledge/index.vue +4 -1
  195. package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
  196. package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
  197. package/departments/quality/SKILL.md +45 -5
  198. package/departments/quality/agents/copy-director.yaml +3 -1
  199. package/departments/quality/agents/tech-director.yaml +3 -1
  200. package/installer/adapters/claude-code.js +46 -2
  201. package/installer/config-seed.js +39 -14
  202. package/installer/doctor.js +8 -0
  203. package/installer/graphify.js +153 -0
  204. package/installer/index.js +20 -0
  205. package/installer/init.js +11 -0
  206. package/installer/update.js +19 -0
  207. package/package.json +1 -1
  208. package/pyproject.toml +3 -1
  209. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  210. package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
  211. package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
  212. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  213. package/scripts/knowledge-index.py +8 -2
  214. package/scripts/synapse-bridge.py +102 -26
@@ -0,0 +1,421 @@
1
+ """Evidence check engine — executable checks behind the Quality Gate (PR-4).
2
+
3
+ Gate 4 verdicts DERIVE from tool output, not persona narrative. This
4
+ module runs real, read-only checks over a project (lint, typecheck,
5
+ tests, coverage, security grep, spellcheck) and returns a structured
6
+ ``EvidenceReport``. Reviewers INTERPRET the report; they cannot
7
+ override it — ``overall == "fail"`` forces REJECTED.
8
+
9
+ Safety contract:
10
+ - subprocesses run with ``cwd=project_dir``, ``capture_output=True``,
11
+ argument lists only (never ``shell=True`` with interpolated input)
12
+ - 300s cap per check; on expiry the process is killed and the check
13
+ reports ``ran=True, passed=None, summary="timeout"``
14
+ - nothing that mutates: no installs, no git, no writes to the project
15
+
16
+ CLI (for hooks/skills)::
17
+
18
+ python -m core.governance.evidence_checks <project_dir> \
19
+ [--checks lint,tests] [--test-command '...'] \
20
+ [--changed-files f1,f2] [--json]
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import re
28
+ import shlex
29
+ import shutil
30
+ import subprocess
31
+ from dataclasses import asdict, dataclass, field
32
+ from pathlib import Path
33
+
34
+ from core.shared.test_evidence import coverage_percent_from_xml
35
+
36
+ TIMEOUT_SECONDS = 300
37
+ COVERAGE_THRESHOLD = 80.0
38
+ ALL_CHECKS: tuple[str, ...] = (
39
+ "lint", "typecheck", "tests", "coverage", "security-grep", "spellcheck",
40
+ )
41
+
42
+ _MAX_SUMMARY_CHARS = 800
43
+ _MAX_GREP_HITS = 20
44
+
45
+ # Obvious-classes security sweep. Line-level, over caller-supplied files.
46
+ _SECURITY_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
47
+ ("aws-access-key", re.compile(r"AKIA[0-9A-Z]{16}")),
48
+ ("api-secret-key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}")),
49
+ ("github-token", re.compile(r"\bghp_[A-Za-z0-9]{20,}")),
50
+ (
51
+ "hardcoded-password",
52
+ re.compile(r"password\s*=\s*['\"][^'\"]{4,}['\"]", re.IGNORECASE),
53
+ ),
54
+ (
55
+ "eval-exec-on-input",
56
+ re.compile(
57
+ r"\b(?:eval|exec)\s*\(.*(?:input|request|argv|params|body)",
58
+ re.IGNORECASE,
59
+ ),
60
+ ),
61
+ ("sql-percent-interpolation", re.compile(r"execute\s*\([^)]*['\"]\s*%")),
62
+ ("sql-fstring-interpolation", re.compile(r"execute\s*\(\s*f['\"]")),
63
+ ("curl-pipe-shell", re.compile(r"curl[^|\n]*\|\s*(?:ba|z)?sh\b")),
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class CheckResult:
69
+ """Outcome of one executable check."""
70
+
71
+ check: str
72
+ ran: bool
73
+ passed: bool | None
74
+ command: str
75
+ exit_code: int | None
76
+ summary: str
77
+ details_path: str | None = None
78
+
79
+
80
+ @dataclass
81
+ class EvidenceReport:
82
+ """All check results plus the derived overall evidence status."""
83
+
84
+ project_dir: str
85
+ overall: str # "pass" | "fail" | "insufficient-evidence"
86
+ results: list[CheckResult] = field(default_factory=list)
87
+
88
+ def to_dict(self) -> dict:
89
+ return {
90
+ "project_dir": self.project_dir,
91
+ "overall": self.overall,
92
+ "results": [asdict(r) for r in self.results],
93
+ }
94
+
95
+
96
+ # ─── Subprocess plumbing ────────────────────────────────────────────────
97
+
98
+
99
+ def _tail(text: str, limit: int = _MAX_SUMMARY_CHARS) -> str:
100
+ text = (text or "").strip()
101
+ return text[-limit:] if len(text) > limit else text
102
+
103
+
104
+ def _skip(check: str, reason: str) -> CheckResult:
105
+ return CheckResult(
106
+ check=check, ran=False, passed=None, command="",
107
+ exit_code=None, summary=reason,
108
+ )
109
+
110
+
111
+ def _run(
112
+ check: str, cmd: list[str], project_dir: Path, timeout: int,
113
+ ) -> CheckResult:
114
+ """Run one read-only project command; capture exit code + tail."""
115
+ command_str = " ".join(cmd)
116
+ try:
117
+ proc = subprocess.run(
118
+ cmd, cwd=project_dir, capture_output=True, text=True,
119
+ timeout=timeout,
120
+ )
121
+ except FileNotFoundError:
122
+ return _skip(check, f"tool not found: {cmd[0]}")
123
+ except subprocess.TimeoutExpired:
124
+ # subprocess.run kills the child on expiry before raising.
125
+ return CheckResult(
126
+ check=check, ran=True, passed=None, command=command_str,
127
+ exit_code=None, summary="timeout",
128
+ )
129
+ output = _tail(proc.stdout.strip() or proc.stderr.strip())
130
+ return CheckResult(
131
+ check=check, ran=True, passed=proc.returncode == 0,
132
+ command=command_str, exit_code=proc.returncode, summary=output,
133
+ )
134
+
135
+
136
+ # ─── Applicability detection ────────────────────────────────────────────
137
+
138
+
139
+ def _suffixes(changed_files: list[str] | None) -> set[str]:
140
+ return {Path(f).suffix for f in changed_files or []}
141
+
142
+
143
+ def _has_python(project_dir: Path, changed_files: list[str] | None) -> bool:
144
+ if ".py" in _suffixes(changed_files):
145
+ return True
146
+ if (project_dir / "pyproject.toml").is_file():
147
+ return True
148
+ return any(project_dir.glob("*.py")) or any(project_dir.glob("*/*.py"))
149
+
150
+
151
+ def _package_json_script(project_dir: Path, script: str) -> bool:
152
+ pkg = project_dir / "package.json"
153
+ if not pkg.is_file():
154
+ return False
155
+ try:
156
+ scripts = json.loads(pkg.read_text(encoding="utf-8")).get("scripts", {})
157
+ except (json.JSONDecodeError, OSError):
158
+ return False
159
+ value = str(scripts.get(script, ""))
160
+ return bool(value) and "no test specified" not in value
161
+
162
+
163
+ def _mypy_configured(project_dir: Path) -> bool:
164
+ if (project_dir / "mypy.ini").is_file():
165
+ return True
166
+ for name in ("pyproject.toml", "setup.cfg"):
167
+ path = project_dir / name
168
+ try:
169
+ text = path.read_text(encoding="utf-8") if path.is_file() else ""
170
+ except OSError:
171
+ text = ""
172
+ if "[tool.mypy]" in text or "[mypy]" in text:
173
+ return True
174
+ return False
175
+
176
+
177
+ # ─── Individual checks ──────────────────────────────────────────────────
178
+
179
+
180
+ def _check_lint(
181
+ project_dir: Path, changed: list[str] | None,
182
+ test_command: str | None, timeout: int,
183
+ ) -> CheckResult:
184
+ if _has_python(project_dir, changed) and shutil.which("ruff"):
185
+ return _run("lint", ["ruff", "check", "."], project_dir, timeout)
186
+ if _package_json_script(project_dir, "lint"):
187
+ return _run(
188
+ "lint", ["npm", "run", "--silent", "lint"], project_dir, timeout,
189
+ )
190
+ pint = project_dir / "vendor" / "bin" / "pint"
191
+ if pint.is_file():
192
+ return _run("lint", [str(pint), "--test"], project_dir, timeout)
193
+ return _skip("lint", "no lint tooling detected (ruff/eslint/pint)")
194
+
195
+
196
+ def _check_typecheck(
197
+ project_dir: Path, changed: list[str] | None,
198
+ test_command: str | None, timeout: int,
199
+ ) -> CheckResult:
200
+ if _mypy_configured(project_dir) and shutil.which("mypy"):
201
+ return _run("typecheck", ["mypy", "."], project_dir, timeout)
202
+ if (project_dir / "tsconfig.json").is_file():
203
+ local_tsc = project_dir / "node_modules" / ".bin" / "tsc"
204
+ if local_tsc.is_file():
205
+ return _run(
206
+ "typecheck", [str(local_tsc), "--noEmit"], project_dir, timeout,
207
+ )
208
+ if shutil.which("tsc"):
209
+ return _run("typecheck", ["tsc", "--noEmit"], project_dir, timeout)
210
+ return _skip("typecheck", "tsconfig.json present but tsc not installed")
211
+ return _skip("typecheck", "no typecheck configuration detected")
212
+
213
+
214
+ def _check_tests(
215
+ project_dir: Path, changed: list[str] | None,
216
+ test_command: str | None, timeout: int,
217
+ ) -> CheckResult:
218
+ if test_command:
219
+ return _run("tests", shlex.split(test_command), project_dir, timeout)
220
+ if _has_python(project_dir, changed) and shutil.which("pytest"):
221
+ return _run("tests", ["pytest", "-q"], project_dir, timeout)
222
+ if _package_json_script(project_dir, "test"):
223
+ return _run(
224
+ "tests", ["npm", "test", "--silent"], project_dir, timeout,
225
+ )
226
+ return _skip("tests", "no test runner detected (pytest/npm test)")
227
+
228
+
229
+ def _junit_result(junit: Path) -> CheckResult:
230
+ try:
231
+ text = junit.read_text(encoding="utf-8")
232
+ except OSError:
233
+ text = ""
234
+ failures = re.search(r'failures="(\d+)"', text)
235
+ errors = re.search(r'errors="(\d+)"', text)
236
+ if failures is None and errors is None:
237
+ return CheckResult(
238
+ check="coverage", ran=True, passed=None,
239
+ command=f"parse:{junit.name}", exit_code=None,
240
+ summary="junit.xml present but unparseable",
241
+ details_path=str(junit),
242
+ )
243
+ failed = sum(int(m.group(1)) for m in (failures, errors) if m)
244
+ return CheckResult(
245
+ check="coverage", ran=True, passed=failed == 0,
246
+ command=f"parse:{junit.name}", exit_code=None,
247
+ summary=f"junit: {failed} failures/errors", details_path=str(junit),
248
+ )
249
+
250
+
251
+ def _check_coverage(
252
+ project_dir: Path, changed: list[str] | None,
253
+ test_command: str | None, timeout: int,
254
+ ) -> CheckResult:
255
+ coverage_xml = project_dir / "coverage.xml"
256
+ if coverage_xml.is_file():
257
+ percent = coverage_percent_from_xml(coverage_xml)
258
+ if percent is None:
259
+ return CheckResult(
260
+ check="coverage", ran=True, passed=None,
261
+ command="parse:coverage.xml", exit_code=None,
262
+ summary="coverage.xml present but unparseable",
263
+ details_path=str(coverage_xml),
264
+ )
265
+ return CheckResult(
266
+ check="coverage", ran=True,
267
+ passed=percent >= COVERAGE_THRESHOLD,
268
+ command="parse:coverage.xml", exit_code=None,
269
+ summary=(
270
+ f"coverage {percent:.1f}% "
271
+ f"(threshold {COVERAGE_THRESHOLD:.0f}%)"
272
+ ),
273
+ details_path=str(coverage_xml),
274
+ )
275
+ junit = project_dir / "junit.xml"
276
+ if junit.is_file():
277
+ return _junit_result(junit)
278
+ return _skip("coverage", "no coverage.xml or junit.xml on disk")
279
+
280
+
281
+ def _grep_file(path: Path) -> list[str]:
282
+ try:
283
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
284
+ except OSError:
285
+ return []
286
+ hits = []
287
+ for lineno, line in enumerate(lines, start=1):
288
+ for name, pattern in _SECURITY_PATTERNS:
289
+ if pattern.search(line):
290
+ hits.append(f"{path}:{lineno} [{name}]")
291
+ return hits
292
+
293
+
294
+ def _check_security_grep(
295
+ project_dir: Path, changed: list[str] | None,
296
+ test_command: str | None, timeout: int,
297
+ ) -> CheckResult:
298
+ if not changed:
299
+ return _skip("security-grep", "no changed files provided")
300
+ hits: list[str] = []
301
+ for name in changed:
302
+ path = Path(name)
303
+ if not path.is_absolute():
304
+ path = project_dir / name
305
+ if path.is_file():
306
+ hits.extend(_grep_file(path))
307
+ summary = (
308
+ "no security patterns matched"
309
+ if not hits
310
+ else "; ".join(hits[:_MAX_GREP_HITS])
311
+ )
312
+ return CheckResult(
313
+ check="security-grep", ran=True, passed=not hits,
314
+ command=f"security-grep over {len(changed)} changed file(s)",
315
+ exit_code=None, summary=_tail(summary),
316
+ )
317
+
318
+
319
+ def _check_spellcheck(
320
+ project_dir: Path, changed: list[str] | None,
321
+ test_command: str | None, timeout: int,
322
+ ) -> CheckResult:
323
+ if not shutil.which("codespell"):
324
+ return _skip("spellcheck", "codespell not installed")
325
+ md_files = [f for f in changed or [] if f.endswith(".md")]
326
+ if not md_files:
327
+ return _skip("spellcheck", "no changed .md files")
328
+ return _run("spellcheck", ["codespell", *md_files], project_dir, timeout)
329
+
330
+
331
+ _CHECK_DISPATCH = {
332
+ "lint": _check_lint,
333
+ "typecheck": _check_typecheck,
334
+ "tests": _check_tests,
335
+ "coverage": _check_coverage,
336
+ "security-grep": _check_security_grep,
337
+ "spellcheck": _check_spellcheck,
338
+ }
339
+
340
+
341
+ # ─── Public API ─────────────────────────────────────────────────────────
342
+
343
+
344
+ def _derive_overall(results: list[CheckResult]) -> str:
345
+ """fail if any ran check failed; insufficient if nothing concluded."""
346
+ if any(r.ran and r.passed is False for r in results):
347
+ return "fail"
348
+ if any(r.ran and r.passed is True for r in results):
349
+ return "pass"
350
+ return "insufficient-evidence"
351
+
352
+
353
+ def run_evidence_checks(
354
+ project_dir: Path,
355
+ changed_files: list[str] | None = None,
356
+ checks: list[str] | None = None,
357
+ test_command: str | None = None,
358
+ timeout: int = TIMEOUT_SECONDS,
359
+ ) -> EvidenceReport:
360
+ """Run the selected checks and derive the overall evidence status."""
361
+ project_dir = Path(project_dir)
362
+ selected = list(checks) if checks else list(ALL_CHECKS)
363
+ results: list[CheckResult] = []
364
+ for name in selected:
365
+ check_fn = _CHECK_DISPATCH.get(name)
366
+ if check_fn is None:
367
+ results.append(_skip(name, f"unknown check: {name}"))
368
+ continue
369
+ results.append(check_fn(project_dir, changed_files, test_command, timeout))
370
+ return EvidenceReport(
371
+ project_dir=str(project_dir),
372
+ overall=_derive_overall(results),
373
+ results=results,
374
+ )
375
+
376
+
377
+ # ─── CLI ────────────────────────────────────────────────────────────────
378
+
379
+
380
+ def _build_parser() -> argparse.ArgumentParser:
381
+ parser = argparse.ArgumentParser(
382
+ prog="python -m core.governance.evidence_checks",
383
+ description="Run executable evidence checks for the Quality Gate.",
384
+ )
385
+ parser.add_argument("project_dir", type=Path)
386
+ parser.add_argument("--checks", help="comma-separated subset of checks")
387
+ parser.add_argument("--test-command", help="override for the tests check")
388
+ parser.add_argument("--changed-files", help="comma-separated changed files")
389
+ parser.add_argument("--json", action="store_true", help="emit JSON report")
390
+ return parser
391
+
392
+
393
+ def _csv(value: str | None) -> list[str] | None:
394
+ if not value:
395
+ return None
396
+ return [item.strip() for item in value.split(",") if item.strip()]
397
+
398
+
399
+ def main(argv: list[str] | None = None) -> int:
400
+ args = _build_parser().parse_args(argv)
401
+ report = run_evidence_checks(
402
+ project_dir=args.project_dir,
403
+ changed_files=_csv(args.changed_files),
404
+ checks=_csv(args.checks),
405
+ test_command=args.test_command,
406
+ )
407
+ if args.json:
408
+ print(json.dumps(report.to_dict(), indent=2))
409
+ else:
410
+ for result in report.results:
411
+ state = (
412
+ "SKIP" if not result.ran
413
+ else {True: "PASS", False: "FAIL", None: "N/A"}[result.passed]
414
+ )
415
+ print(f"[{state}] {result.check}: {result.summary}")
416
+ print(f"overall: {report.overall}")
417
+ return {"pass": 0, "fail": 1}.get(report.overall, 2)
418
+
419
+
420
+ if __name__ == "__main__":
421
+ raise SystemExit(main())
@@ -61,3 +61,39 @@ def _has_bypass_marker(text: str, markers: tuple[str, ...]) -> bool:
61
61
 
62
62
  def _is_trivial_length(text: str) -> bool:
63
63
  return len(text.split()) < _TRIVIAL_WORD_THRESHOLD
64
+
65
+
66
+ # ─── kb=N reconciliation (structural honesty PR-2) ──────────────────────
67
+
68
+ _KB_REPORTED_RE: re.Pattern[str] = re.compile(
69
+ r"\[arka:meta\][^\n]*?\bkb=(\d+)", re.IGNORECASE
70
+ )
71
+
72
+
73
+ def parse_reported_kb(response_text: str) -> int | None:
74
+ """Extract the self-reported kb=N from a closing [arka:meta] line.
75
+
76
+ Returns None when the tag or the kb field is absent.
77
+ """
78
+ match = _KB_REPORTED_RE.search(response_text or "")
79
+ return int(match.group(1)) if match else None
80
+
81
+
82
+ def reconcile_kb_count(
83
+ reported: int | None, injected: int | None
84
+ ) -> dict[str, int | bool | None]:
85
+ """Reconcile the self-reported kb=N against the Synapse-injected count.
86
+
87
+ ``injected`` is ground truth (written by synapse-bridge from the L2.5
88
+ layer result); ``reported`` is the model's claim. Warn-only contract:
89
+ ``kb_inflated`` is True only when BOTH values are known and the claim
90
+ exceeds reality. Unknown on either side never flags inflation.
91
+ """
92
+ inflated = (
93
+ reported is not None and injected is not None and reported > injected
94
+ )
95
+ return {
96
+ "kb_reported": reported,
97
+ "kb_injected": injected,
98
+ "kb_inflated": inflated,
99
+ }
@@ -0,0 +1,78 @@
1
+ """Structured Quality Gate verdict schema (PR-4 evidence Quality Gate).
2
+
3
+ ``QGVerdict`` is the contract every QG reviewer subagent must return.
4
+ The verdict is INTERPRETATION of an ``EvidenceReport`` from
5
+ ``core.governance.evidence_checks`` — never narrative alone:
6
+
7
+ - ``evidence_report.overall == "fail"`` forces ``verdict=REJECTED``;
8
+ a persona cannot override failing evidence with prose.
9
+ - ``APPROVED`` requires ``overall == "pass"``, or
10
+ ``insufficient-evidence`` with an explicit justification in notes.
11
+
12
+ ``QG_VERDICT_JSON_SCHEMA`` is the dict to pass as the structured-output
13
+ schema when dispatching reviewers via the Agent tool.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Literal
19
+
20
+ from pydantic import BaseModel, Field, model_validator
21
+
22
+
23
+ class QGBlocker(BaseModel):
24
+ """One concrete issue that blocks approval."""
25
+
26
+ check: str = Field(description="Evidence check or rubric area that failed")
27
+ detail: str = Field(description="What exactly is wrong, with evidence")
28
+ file: str | None = Field(
29
+ default=None, description="File (and line if known) of the issue"
30
+ )
31
+
32
+
33
+ class QGEvidenceSummary(BaseModel):
34
+ """Embedded summary of the EvidenceReport the reviewer interpreted."""
35
+
36
+ overall: Literal["pass", "fail", "insufficient-evidence"]
37
+ checks_ran: list[str] = Field(default_factory=list)
38
+ checks_failed: list[str] = Field(default_factory=list)
39
+ checks_skipped: list[str] = Field(default_factory=list)
40
+
41
+
42
+ class QGVerdict(BaseModel):
43
+ """Binary Quality Gate verdict derived from executable evidence."""
44
+
45
+ verdict: Literal["APPROVED", "REJECTED"]
46
+ evidence_report: QGEvidenceSummary
47
+ blockers: list[QGBlocker] = Field(default_factory=list)
48
+ reviewer: str = Field(description="Reviewer id, e.g. tech-director-francisca")
49
+ model_used: str = Field(description="Model tier the review ran on")
50
+ notes: str = Field(
51
+ default="",
52
+ description=(
53
+ "Interpretation notes; MUST justify approval explicitly when "
54
+ "evidence is insufficient"
55
+ ),
56
+ )
57
+
58
+ @model_validator(mode="after")
59
+ def enforce_evidence_floor(self) -> "QGVerdict":
60
+ """APPROVED cannot coexist with failing evidence (evidence floor)."""
61
+ if self.verdict == "APPROVED" and self.evidence_report.overall == "fail":
62
+ raise ValueError(
63
+ "APPROVED verdict with evidence overall='fail' — a persona "
64
+ "cannot override failing evidence with narrative"
65
+ )
66
+ if (
67
+ self.verdict == "APPROVED"
68
+ and self.evidence_report.overall == "insufficient-evidence"
69
+ and not self.notes.strip()
70
+ ):
71
+ raise ValueError(
72
+ "APPROVED on insufficient-evidence requires explicit "
73
+ "justification in notes"
74
+ )
75
+ return self
76
+
77
+
78
+ QG_VERDICT_JSON_SCHEMA: dict = QGVerdict.model_json_schema()
@@ -54,6 +54,7 @@ class ReviewerVote:
54
54
  comments: str = ""
55
55
  flagged_rules: list[str] = field(default_factory=list)
56
56
  voted_at: str = ""
57
+ evidence_overall: str | None = None # "pass" | "fail" | "insufficient-evidence"
57
58
 
58
59
 
59
60
  @dataclass
@@ -209,6 +210,7 @@ class ReviewWorkflowEngine:
209
210
  opinion: ReviewerOpinion,
210
211
  comments: str = "",
211
212
  flagged_rules: list[str] | None = None,
213
+ evidence_overall: str | None = None,
212
214
  ) -> bool:
213
215
  """Record a reviewer's vote.
214
216
 
@@ -218,9 +220,15 @@ class ReviewWorkflowEngine:
218
220
  opinion: Their vote
219
221
  comments: Optional comments
220
222
  flagged_rules: Constitution rules that were violated
223
+ evidence_overall: EvidenceReport.overall the vote interprets
224
+ ("pass" | "fail" | "insufficient-evidence")
221
225
 
222
226
  Returns:
223
227
  True if vote recorded successfully
228
+
229
+ Raises:
230
+ ValueError: APPROVE vote against evidence_overall == "fail" —
231
+ a reviewer cannot approve over failing executable evidence.
224
232
  """
225
233
  workflow = self._workflows.get(workflow_id)
226
234
  if not workflow:
@@ -229,12 +237,19 @@ class ReviewWorkflowEngine:
229
237
  if workflow.phase not in (ReviewPhase.REVIEW, ReviewPhase.DELIBERATION):
230
238
  return False
231
239
 
240
+ if opinion == ReviewerOpinion.APPROVE and evidence_overall == "fail":
241
+ raise ValueError(
242
+ f"reviewer {reviewer} voted APPROVE with evidence "
243
+ "overall='fail' — evidence floor violated"
244
+ )
245
+
232
246
  vote = ReviewerVote(
233
247
  reviewer=reviewer,
234
248
  opinion=opinion,
235
249
  comments=comments,
236
250
  flagged_rules=flagged_rules or [],
237
251
  voted_at=datetime.now(timezone.utc).isoformat(),
252
+ evidence_overall=evidence_overall,
238
253
  )
239
254
  workflow.votes.append(vote)
240
255
  return True
@@ -276,17 +291,35 @@ class ReviewWorkflowEngine:
276
291
  self._announce_phase_complete(ReviewPhase.DELIBERATION, result)
277
292
  return result
278
293
 
279
- def reach_verdict(self, workflow_id: str, verdict: Verdict, feedback: str = "") -> PhaseResult:
294
+ def reach_verdict(
295
+ self,
296
+ workflow_id: str,
297
+ verdict: Verdict,
298
+ feedback: str = "",
299
+ evidence_overall: str | None = None,
300
+ ) -> PhaseResult:
280
301
  """Record the final verdict.
281
302
 
282
303
  Args:
283
304
  workflow_id: ID of the workflow
284
305
  verdict: Final verdict
285
306
  feedback: Feedback for the submitter
307
+ evidence_overall: EvidenceReport.overall the verdict derives
308
+ from ("pass" | "fail" | "insufficient-evidence")
286
309
 
287
310
  Returns:
288
311
  PhaseResult with verdict outcome
312
+
313
+ Raises:
314
+ ValueError: APPROVED with evidence_overall == "fail" — the
315
+ bookkeeping enforces the evidence floor.
289
316
  """
317
+ if verdict == Verdict.APPROVED and evidence_overall == "fail":
318
+ raise ValueError(
319
+ "APPROVED verdict with evidence overall='fail' — evidence "
320
+ "floor violated; failing checks force REJECTED"
321
+ )
322
+
290
323
  workflow = self._workflows.get(workflow_id)
291
324
  if not workflow:
292
325
  return PhaseResult(
@@ -15,7 +15,8 @@ from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
17
  _COMPLETION_PATTERNS: tuple[re.Pattern[str], ...] = (
18
- re.compile(r"\[arka:phase:13\]", re.IGNORECASE),
18
+ re.compile(r"\[arka:gate:4\]", re.IGNORECASE),
19
+ re.compile(r"\[arka:phase:13\]", re.IGNORECASE), # legacy, v4.1 window
19
20
  re.compile(r"\barc complete\b", re.IGNORECASE),
20
21
  )
21
22
 
@@ -0,0 +1,12 @@
1
+ """Consolidated hook entrypoints (PR-6 v4.1.0 — hook hygiene).
2
+
3
+ One python process per hook event. Each module exposes
4
+ ``main(stdin_json) -> int`` and is invoked by the thin bash wrappers in
5
+ ``config/hooks/*.sh`` as ``python3 -m core.hooks.<event>``.
6
+
7
+ CRITICAL degradation contract: ambient python3 lacks PyYAML on some
8
+ machines. This package and every entrypoint import ONLY stdlib at module
9
+ level; anything that can pull yaml (core.workflow.*, core.governance.*,
10
+ core.synapse engine, ...) is imported lazily inside try/except so
11
+ stdlib-only paths (budget check, gotchas, nudges) still run.
12
+ """