sud-cli 0.1.1__py3-none-any.whl

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 (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/mcp_server.py ADDED
@@ -0,0 +1,1594 @@
1
+ """sud-cli MCP stdio server: JSON-RPC 2.0 tool surface.
2
+
3
+ v2.2.0 (боевая рецензия colleague-legal, A2A 001a16046ebe, 21.10):
4
+ - типизированные inputSchema у всех инструментов (properties/required/enum/
5
+ default) вместо единого {"type":"object"} — агент видит сигнатуры;
6
+ - additionalProperties=false: неизвестный параметр — явная ошибка, а не
7
+ молчаливое игнорирование (fail-closed);
8
+ - выведены недостающие тулзы: case_history, payment, deferral,
9
+ efrsb_companies/persons/publications/managers, sudrf_live, mos_live
10
+ (через case-search bridge; без SUD_CLI_BRIDGE_LIVE возвращают
11
+ status=blocked и не порождают дочерних процессов).
12
+
13
+ v2.3.0 (письмо A2A colleague-legal 001a1608, 21.10):
14
+ - sud_cli_case_diff (первичное по письму, вариант «в»): offline-дифф двух
15
+ сохранённых snapshots одного дела (sud_cli.case_diff.diff_case_snapshots,
16
+ тот же код, что в CLI 'case-search case-diff');
17
+ - sud_cli_npa_compare (аддитивно, вариант «а»): сравнение двух редакций
18
+ сохранённого НПА-фикстура (sud_cli.db.compare_npa_editions);
19
+ - доменные ошибки DiffError/SearchError конвертируются в ValueError внутри
20
+ _call: handle_request ловит только (KeyError, TypeError, ValueError,
21
+ OSError), поэтому без конверсии serve_stdio падал бы вместо того, чтобы
22
+ вернуть клиенту isError=True с текстом ошибки.
23
+
24
+ v2.3.1 (боевой аудит colleague-legal, ТЗ task-brief, письмо A2A 001a1609):
25
+ - sud_cli_fees: claim_amount объявлен number (integer терял копейки), база
26
+ расчёта доходит до движка как Decimal; имя вида пошлины принимается и как
27
+ fee_kind (имя движка), и как kind (CLI --kind) — required через anyOf;
28
+ - CLI-алиасы объявлены в схемах там, где вызов по CLI-имени падал: fees
29
+ applicant, limitation type/years/months, interest start_date/end_date,
30
+ case_history case_id, efrsb_persons name;
31
+ - два имени с разными значениями — явная ошибка, а не молчаливый выбор одной
32
+ стороны (_pick);
33
+ - enum'ы приведены к значениям, которые действительно принимает движок:
34
+ юрисдикции пошлин (arbitration|general_jurisdiction|kas, алиасы
35
+ arbitral/soyu распознаёт хендлер), секции sudact, applicant, виды пошлины;
36
+ - serverInfo.version больше не константа, а sud_cli.__version__ — раньше
37
+ версия сервера расходилась с пакетом молча.
38
+
39
+ v2.11.0 (task-briefa §1, S-80): MCP/CLI паритет + права + идемпотентность:
40
+ - каждый инструмент несёт поле permission: read|write (источник — WRITE_TOOLS,
41
+ единый для всех 45); write-тулы требуют явный confirm: true (без него
42
+ status=needs_confirmation, запись не производится) и принимают
43
+ optional idempotency_key (реестр state-dir/idempotency.json, окно 10 мин,
44
+ повтор = первый результат + idempotent_replay=true в теле);
45
+ - +10 офлайн-утилитных тулов паритета (anchor/confirm/waive/list/audit/
46
+ autofill, calendar agenda/ics, reminders due, case-search alerts);
47
+ ни один сетевой live-тул в этой волне не добавлен;
48
+ - mcp_exposed_commands()/TOOL_CLI_PATH — карта паритета против дерева CLI,
49
+ live-тулы помечены LIVE_GATED_TOOLS и не входят в обязательный список.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import json
54
+ import os
55
+ import sys
56
+ from datetime import date, timedelta
57
+ from decimal import Decimal
58
+ from pathlib import Path
59
+ from typing import Any
60
+
61
+ from . import __version__
62
+
63
+
64
+ DATE = {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "ISO date YYYY-MM-DD"}
65
+
66
+ #: Виды пошлины из legal_rules.json (engine-имена; CLI вызывает их как --kind).
67
+ FEE_KINDS = ["property_claim", "court_order", "non_property_claim", "appeal",
68
+ "cassation_first", "cassation_supreme", "bankruptcy_petition",
69
+ "bankruptcy_creditor_claim"]
70
+
71
+ #: Юрисдикции: реальные ключи legal_rules.json. CLI знает только их; схема до
72
+ #: v2.3.1 рекламировала arbitral/soyu/constitutional, которых в движке нет.
73
+ FEE_JURISDICTIONS = ["arbitration", "general_jurisdiction", "kas"]
74
+
75
+ #: Алиасы юрисдикции для вызова по MCP (обратная совместимость со старым enum).
76
+ JURISDICTION_ALIASES = {
77
+ "arbitral": "arbitration", "as": "arbitration",
78
+ "soyu": "general_jurisdiction", "general": "general_jurisdiction",
79
+ "kas": "kas",
80
+ }
81
+
82
+ #: Канонические секции sudact (normalize_section); 'general' движок не знает.
83
+ SUDACT_SECTIONS = ["arbitral", "regular", "magistrate", "vsrf"]
84
+
85
+ TOOLS: list[dict[str, Any]] = [
86
+ {"name": "sud_cli_limitation", "description": "Calculate a civil limitation period.",
87
+ "schema": {"type": "object", "properties": {
88
+ "start": {**DATE, "description": "Начало течения срока"},
89
+ "period_type": {"type": "string", "default": "general", "description": "Тип срока (general/special/custom_years и др.); CLI-алиас: type"},
90
+ "type": {"type": "string", "description": "CLI-алиас period_type (--type)"},
91
+ "claim_notice_days": {"type": "integer", "default": 0, "description": "Дни досудебного порядка (+30)"},
92
+ "check_date": {**DATE, "description": "Дата проверки (default сегодня)"},
93
+ "custom_years": {"type": "integer", "default": 0, "description": "Лет (period_type=custom_years); CLI-алиас: years"},
94
+ "custom_months": {"type": "integer", "default": 0, "description": "Месяцев; CLI-алиас: months"},
95
+ "years": {"type": "integer", "description": "CLI-алиас custom_years (--years)"},
96
+ "months": {"type": "integer", "description": "CLI-алиас custom_months (--months)"}},
97
+ "required": ["start"], "additionalProperties": False}},
98
+ {"name": "sud_cli_deadlines", "description": "Calculate a procedural deadline.",
99
+ "schema": {"type": "object", "properties": {
100
+ "deadline_key": {"type": "string", "description": "Ключ из утверждённой матрицы сроков"},
101
+ "event_date": {**DATE}, "region": {"type": "string"}, "tz": {"type": "string"},
102
+ "case_id": {"type": "string"},
103
+ "deadlines_command": {"type": "string"},
104
+ "confirm": {"type": "boolean"}, "by": {"type": "string"},
105
+ "evidence": {"type": "string"}, "norm": {"type": "string"},
106
+ "waive": {"type": "boolean"}},
107
+ "required": ["deadline_key"], "additionalProperties": False}},
108
+ {"name": "sud_cli_deadlines_matrix", "description": "Deadline matrix (control points) for a case.",
109
+ "schema": {"type": "object", "properties": {
110
+ "case_id": {"type": "string", "description": "Case id; CLI-алиас: id"},
111
+ "id": {"type": "string", "description": "CLI-алиас case_id"},
112
+ "instance": {"type": "string", "enum": ["apk", "gpk", "kas"], "description": "Override инстанции"},
113
+ "as_of": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": "Дата as-of (не влияет на расчёт)"},
114
+ "missing": {"type": "boolean", "description": "Только точки needs_anchor"}},
115
+ "anyOf": [{"required": ["case_id"]}, {"required": ["id"]}],
116
+ "additionalProperties": False}},
117
+
118
+ {"name": "sud_cli_fees", "description": "Calculate a state fee.",
119
+ "schema": {"type": "object", "properties": {
120
+ "jurisdiction": {"type": "string", "enum": FEE_JURISDICTIONS,
121
+ "description": "Юрисдикция (CLI --jurisdiction); хендлер принимает также алиасы arbitral/as→arbitration, soyu/general→general_jurisdiction"},
122
+ "fee_kind": {"type": "string", "enum": FEE_KINDS, "description": "Вид пошлины из правил 259-ФЗ (имя движка); CLI-алиас: kind"},
123
+ "kind": {"type": "string", "enum": FEE_KINDS, "description": "CLI-алиас fee_kind (--kind)"},
124
+ "applicant_type": {"type": "string", "enum": ["individual", "entity"], "description": "Обязателен для фиксированных пошлин: физлицо/организация; CLI-алиас: applicant"},
125
+ "applicant": {"type": "string", "enum": ["individual", "entity"], "description": "CLI-алиас applicant_type (--applicant)"},
126
+ "claim_amount": {"type": "number", "description": "Цена иска в рублях; копейки сохраняются (Decimal), округление п. 6 ст. 52 НК РФ — только на итоге"},
127
+ "claim_class": {"type": "string"},
128
+ "as_of": {"type": "string", "format": "date",
129
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
130
+ "description": "Тариф на дату (ISO YYYY-MM-DD): 2015-01-01..2024-09-08 — редакция 221-ФЗ, с 2024-09-09 — действующая 259-ФЗ; раньше 2015-01-01 — действующий тариф с warning в meta. Без поля считается по действующему тарифу (прежний контракт)"},
131
+ },
132
+ "required": ["jurisdiction"],
133
+ "anyOf": [{"required": ["fee_kind"]}, {"required": ["kind"]}],
134
+ "additionalProperties": False}},
135
+ {"name": "sud_cli_interest", "description": "Calculate statutory interest.",
136
+ "schema": {"type": "object", "properties": {
137
+ "amount": {"type": "number", "description": "Остаток долга (проценты на просрочку)"},
138
+ "start": {**DATE, "description": "Начало периода; CLI-алиас: start_date"},
139
+ "end": {**DATE, "description": "Конец периода; CLI-алиас: end_date"},
140
+ "start_date": {**DATE, "description": "CLI-алиас start (--from/--start)"},
141
+ "end_date": {**DATE, "description": "CLI-алиас end (--to/--end)"},
142
+ "article": {"type": "string", "enum": ["395", "317.1"], "default": "395"},
143
+ "payments": {"type": "array", "description": "Частичные погашения [{date, amount}]"}},
144
+ "required": ["amount"],
145
+ "anyOf": [{"required": ["start", "end"]}, {"required": ["start_date", "end_date"]}],
146
+ "additionalProperties": False}},
147
+ {"name": "sud_cli_practice_search", "description": "Search judicial practice (sudact).",
148
+ "schema": {"type": "object", "properties": {
149
+ "query": {"type": "string"},
150
+ "section": {"type": "string", "enum": SUDACT_SECTIONS, "default": "arbitral"},
151
+ "page": {"type": "integer", "default": 1}, "limit": {"type": "integer", "default": 10}},
152
+ "required": ["query"], "additionalProperties": False}},
153
+ {"name": "sud_cli_kad_search", "description": "Search KAD by participant.",
154
+ "schema": {"type": "object", "properties": {
155
+ "inn": {"type": "string"}, "ogrn": {"type": "string"}, "name": {"type": "string"},
156
+ "page": {"type": "integer", "default": 1}},
157
+ "anyOf": [{"required": ["inn"]}, {"required": ["ogrn"]}, {"required": ["name"]}],
158
+ "additionalProperties": False}},
159
+ {"name": "sud_cli_timeline_audit", "description": "Detect procedural timeline anomalies.",
160
+ "schema": {"type": "object", "properties": {
161
+ "events": {"type": "array", "items": {"type": "object"},
162
+ "description": "Хронология: [{timestamp (ISO, обязателен), action|doc_type, id, ...}] — движок читает event['timestamp']"}},
163
+ "required": ["events"], "additionalProperties": False}},
164
+ {"name": "sud_cli_npa_as_of", "description": "Get a historical legal norm edition.",
165
+ "schema": {"type": "object", "properties": {
166
+ "act": {"type": "string", "description": "Акт: gk_rf | uk_rf | nk_rf (ключи data/npa_historical_editions.json)"},
167
+ "article": {"type": "string"}, "date": {**DATE}},
168
+ "required": ["act", "article", "date"], "additionalProperties": False}},
169
+ {"name": "sud_cli_verify_quote", "description": "Verify a quoted fragment against npa bundle / case events / text file (verdict exact|fuzzy|not_found|edition_unavailable; правило «тексты только из реестра» — без «ok на глаз»).",
170
+ "schema": {"type": "object", "properties": {
171
+ "text": {"type": "string", "description": "Цитируемый фрагмент (не пустой)"},
172
+ "source": {"type": "string", "enum": ["npa", "case", "file"]},
173
+ "act": {"type": "string", "description": "source=npa: ключ бандла (gk_rf|uk_rf|nk_rf)"},
174
+ "article": {"type": "string"}, "date": {**DATE},
175
+ "case_id": {"type": "string", "description": "source=case: id дела в локальном реестре"},
176
+ "path": {"type": "string", "description": "source=file: plain-text файл"},
177
+ "max_fuzzy": {"type": "number", "minimum": 0, "maximum": 1, "default": 0,
178
+ "description": "0 = fuzzy выключен; порог ratio >= 1-max_fuzzy"}},
179
+ "required": ["text", "source"], "additionalProperties": False}},
180
+ {"name": "sud_cli_case_history", "description": "Case fate: all instances of a case number via sudact.",
181
+ "schema": {"type": "object", "properties": {
182
+ "case_number": {"type": "string", "description": "Номер дела; CLI-алиас: case_id"},
183
+ "case_id": {"type": "string", "description": "CLI-алиас case_number (позиционный аргумент case-history)"},
184
+ "section": {"type": "string", "enum": SUDACT_SECTIONS, "default": "arbitral"},
185
+ "max_related": {"type": "integer", "default": 5}, "limit": {"type": "integer", "default": 10}},
186
+ "anyOf": [{"required": ["case_number"]}, {"required": ["case_id"]}],
187
+ "additionalProperties": False}},
188
+ {"name": "sud_cli_payment", "description": "Payment requisites and purpose for a state fee.",
189
+ "schema": {"type": "object", "properties": {
190
+ "jurisdiction": {"type": "string"}, "amount": {"type": "integer"},
191
+ "court": {"type": "string"}, "defendant": {"type": "string"}},
192
+ "required": ["jurisdiction", "amount", "court", "defendant"], "additionalProperties": False}},
193
+ {"name": "sud_cli_deferral", "description": "Draft a motion to defer the state fee.",
194
+ "schema": {"type": "object", "properties": {
195
+ "applicant": {"type": "string"}, "court": {"type": "string"},
196
+ "amount": {"type": "integer"}, "reason": {"type": "string"}},
197
+ "required": ["applicant", "court", "amount", "reason"], "additionalProperties": False}},
198
+ {"name": "sud_cli_fssp_check_plan", "description": "FSSP BDIP manual-check plan: URL, params, checklist (human solves captcha; no network here).",
199
+ "schema": {"type": "object", "properties": {
200
+ "kind": {"type": "string", "enum": ["fiz", "ur"]},
201
+ "name": {"type": "string"}, "inn": {"type": "string"}, "ogrn": {"type": "string"},
202
+ "birth_date": {"type": "string"}, "region": {"type": "string"}},
203
+ "required": ["kind"], "additionalProperties": False}},
204
+ {"name": "sud_cli_fssp_record", "description": "Record a manual FSSP check result into case evidence registry (provenance: who/when/result).",
205
+ "schema": {"type": "object", "properties": {
206
+ "case_id": {"type": "string"}, "subject": {"type": "string"},
207
+ "result": {"type": "string", "enum": ["none", "found", "inconclusive"]},
208
+ "checked_by": {"type": "string"}, "notes": {"type": "string"},
209
+ "file": {"type": "string", "description": "path to screenshot/PDF evidence"}},
210
+ "required": ["case_id", "subject", "result", "checked_by"], "additionalProperties": False}},
211
+ {"name": "sud_cli_fssp_stats", "description": "Official FSSP BDIP aggregate statistics (public CSV, reference data).",
212
+ "schema": {"type": "object", "properties": {"region": {"type": "string"}},
213
+ "additionalProperties": False}},
214
+ {"name": "sud_cli_efrsb_link", "description": "EFRSB manual-check plan for humans (act 25.10): valid search link + steps; KAD stays the automated primary path. No network.",
215
+ "schema": {"type": "object", "properties": {
216
+ "kind": {"type": "string", "enum": ["ul", "ip", "fl"]},
217
+ "name": {"type": "string"}, "inn": {"type": "string"}, "ogrn": {"type": "string"}},
218
+ "required": ["kind"], "additionalProperties": False}},
219
+ {"name": "sud_cli_plenum_search", "description": "Search the VS/VAS rulings corpus (plenum/obzor paragraphs with sha256-verified quotes).",
220
+ "schema": {"type": "object", "properties": {"text": {"type": "string"}},
221
+ "required": ["text"], "additionalProperties": False}},
222
+ {"name": "sud_cli_license_info", "description": "AGPL section 13 interface: license, build mode, source retrieval, build_flags (no network).",
223
+ "schema": {"type": "object", "properties": {
224
+ "with_build_flags": {"type": "boolean", "default": True,
225
+ "description": "включить HMAC-build_flag метки сборки (пусто без SUD_BUILD_SECRET)"}},
226
+ "additionalProperties": False}},
227
+ {"name": "sud_cli_egrul_search", "description": "EGRYUL/EGRIP open counterparty check via egrul.nalog.ru (FNS public registry).",
228
+ "schema": {"type": "object", "properties": {
229
+ "query": {"type": "string", "description": "ИНН, ОГРН или наименование"},
230
+ "name_eq": {"type": "boolean", "description": "точное соответствие наименования", "default": False},
231
+ "rows_hint": {"type": "integer", "default": 1}}, "additionalProperties": False}},
232
+ {"name": "sud_cli_efrsb_companies", "description": "EFRSB bankruptcy search for legal entities/IP.",
233
+ "schema": {"type": "object", "properties": {
234
+ "inn": {"type": "string"}, "ogrn": {"type": "string"}, "name": {"type": "string"},
235
+ "limit": {"type": "integer", "default": 5}}, "additionalProperties": False}},
236
+ {"name": "sud_cli_efrsb_persons", "description": "EFRSB bankruptcy search for individuals.",
237
+ "schema": {"type": "object", "properties": {
238
+ "full_name": {"type": "string", "description": "ФИО; CLI-алиас: name"},
239
+ "name": {"type": "string", "description": "CLI-алиас full_name (--name)"},
240
+ "birth_date": {**DATE}, "limit": {"type": "integer", "default": 5}},
241
+ "anyOf": [{"required": ["full_name"]}, {"required": ["name"]}],
242
+ "additionalProperties": False}},
243
+ {"name": "sud_cli_efrsb_publications", "description": "EFRSB publications for a bankruptcy case.",
244
+ "schema": {"type": "object", "properties": {
245
+ "case_id": {"type": "string"}, "limit": {"type": "integer", "default": 10}},
246
+ "required": ["case_id"], "additionalProperties": False}},
247
+ {"name": "sud_cli_efrsb_managers", "description": "EFRSB arbitration managers lookup.",
248
+ "schema": {"type": "object", "properties": {
249
+ "search": {"type": "string"}, "inn": {"type": "string"}, "sro": {"type": "string"},
250
+ "limit": {"type": "integer", "default": 5}}, "additionalProperties": False}},
251
+ {"name": "sud_cli_sudrf_live", "description": "Look up a GAS Pravosudie case (bridge; requires SUD_CLI_BRIDGE_LIVE).",
252
+ "schema": {"type": "object", "properties": {"case_id": {"type": "string"}},
253
+ "required": ["case_id"], "additionalProperties": False}},
254
+ {"name": "sud_cli_mos_live", "description": "Look up a Moscow court case (bridge; requires SUD_CLI_BRIDGE_LIVE).",
255
+ "schema": {"type": "object", "properties": {"case_id": {"type": "string"}, "court_slug": {"type": "string"}},
256
+ "required": ["case_id"], "additionalProperties": False}},
257
+ {"name": "sud_cli_case_diff", "description": "Diff two saved snapshots of the same case (offline, no court contact).",
258
+ "schema": {"type": "object", "properties": {
259
+ "old": {"type": "string", "description": "Path to earlier snapshot JSON"},
260
+ "new": {"type": "string", "description": "Path to later snapshot JSON"},
261
+ "old_snapshot": {"type": "string"}, "new_snapshot": {"type": "string"}},
262
+ "anyOf": [{"required": ["old", "new"]}, {"required": ["old_snapshot", "new_snapshot"]}],
263
+ "additionalProperties": False}},
264
+ {"name": "sud_cli_npa_compare", "description": "Compare two editions of a saved NPA fixture.",
265
+ "schema": {"type": "object", "properties": {
266
+ "doc_id": {"type": "string"}, "edition1": {"type": "string"}, "edition2": {"type": "string"}},
267
+ "required": ["doc_id", "edition1", "edition2"], "additionalProperties": False}},
268
+ {"name": "sud_cli_case_new", "description": "Create a case in the local registry.",
269
+ "schema": {"type": "object", "properties": {
270
+ "number": {"type": "string"}, "case_number": {"type": "string"},
271
+ "court": {"type": "string"},
272
+ "plaintiff": {"type": "string"}, "defendant": {"type": "string"},
273
+ "owner": {"type": "string"}, "status": {"type": "string"}},
274
+ "anyOf": [{"required": ["number"]}, {"required": ["case_number"]}],
275
+ "required": ["court", "plaintiff", "defendant"], "additionalProperties": False}},
276
+ {"name": "sud_cli_case_set", "description": "Update case fields.",
277
+ "schema": {"type": "object", "properties": {
278
+ "case_id": {"type": "string"}, "id": {"type": "string"},
279
+ "status": {"type": "string"}, "owner": {"type": "string"}, "closed_at": {"type": "string"}},
280
+ "anyOf": [{"required": ["case_id"]}, {"required": ["id"]}],
281
+ "additionalProperties": False}},
282
+ {"name": "sud_cli_case_list", "description": "List cases with filters.",
283
+ "schema": {"type": "object", "properties": {
284
+ "status": {"type": "string"}, "owner": {"type": "string"},
285
+ "court": {"type": "string"}, "party": {"type": "string"}},
286
+ "additionalProperties": False}},
287
+ {"name": "sud_cli_case_card", "description": "Aggregate case card.",
288
+ "schema": {"type": "object", "properties": {
289
+ "case_id": {"type": "string"}, "id": {"type": "string"}},
290
+ "anyOf": [{"required": ["case_id"]}, {"required": ["id"]}],
291
+ "additionalProperties": False}},
292
+ {"name": "sud_cli_case_amount_add", "description": "Add an amount to a case.",
293
+ "schema": {"type": "object", "properties": {
294
+ "case_id": {"type": "string"}, "id": {"type": "string"},
295
+ "kind": {"type": "string"},
296
+ "amount": {"type": ["number", "string"]}, "note": {"type": "string"}},
297
+ "anyOf": [{"required": ["case_id"]}, {"required": ["id"]}],
298
+ "required": ["kind", "amount"], "additionalProperties": False}},
299
+ {"name": "sud_cli_case_doc_add", "description": "Add a document reference to a case.",
300
+ "schema": {"type": "object", "properties": {
301
+ "case_id": {"type": "string"}, "id": {"type": "string"},
302
+ "path": {"type": "string"}, "title": {"type": "string"}},
303
+ "anyOf": [{"required": ["case_id"]}, {"required": ["id"]}],
304
+ "required": ["path"], "additionalProperties": False}},
305
+ {"name": "sud_cli_case_note_add", "description": "Add a note to a case.",
306
+ "schema": {"type": "object", "properties": {
307
+ "case_id": {"type": "string"}, "id": {"type": "string"},
308
+ "text": {"type": "string"}, "author": {"type": "string"}},
309
+ "anyOf": [{"required": ["case_id"]}, {"required": ["id"]}],
310
+ "required": ["text"], "additionalProperties": False}},
311
+ {"name": "sud_cli_conflict_check", "description": "Conflict-of-interest heuristic over the local case registry (name-exact).",
312
+ "schema": {"type": "object", "properties": {
313
+ "plaintiff": {"type": "string"}, "defendant": {"type": "string"},
314
+ "court": {"type": "string"}, "case_number": {"type": "string"},
315
+ "case_id": {"type": "string"}, "id": {"type": "string"},
316
+ "parties": {"type": "array", "items": {"type": "string"}}},
317
+ "additionalProperties": False}},
318
+ {"name": "sud_cli_jurisdiction_check", "description": "Jurisdiction venue helper over VERIFIED rules.",
319
+ "schema": {"type": "object", "properties": {
320
+ "code": {"type": "string", "enum": ["apk", "gpk", "kas"]},
321
+ "claim": {"type": "string"},
322
+ "party_org": {"type": "boolean"}, "party_phys": {"type": "boolean"},
323
+ "unknown_rf_location": {"type": "boolean"}, "immovable": {"type": "boolean"},
324
+ "corporate": {"type": "boolean"}, "bankruptcy": {"type": "boolean"},
325
+ "ip_rights": {"type": "boolean"}, "contract_forum": {"type": "boolean"},
326
+ "arbitration_clause": {"type": "boolean"}, "multiple_defendants": {"type": "boolean"},
327
+ "foreign_element": {"type": "boolean"}, "consumer": {"type": "boolean"},
328
+ "alimony": {"type": "boolean"}},
329
+ "required": ["code", "claim"], "additionalProperties": False}},
330
+ {"name": "sud_cli_fee_exemption", "description": "Fee exemption check over VERIFIED category sample (333.35-37).",
331
+ "schema": {"type": "object", "properties": {
332
+ "jurisdiction": {"type": "string", "enum": ["apk", "gpk", "kas", "arbitration", "general_jurisdiction"]},
333
+ "category": {"type": "string"}},
334
+ "required": ["jurisdiction", "category"], "additionalProperties": False}},
335
+ {"name": "sud_cli_fee_refund", "description": "Fee refund per 333.40 VERIFIED grounds with share/conditions.",
336
+ "schema": {"type": "object", "properties": {
337
+ "paid": {"type": "string"}, "ground": {"type": "string"},
338
+ "claim_admitted": {"type": "boolean"}, "plaintiff_default": {"type": "boolean"},
339
+ "transferred_to_arbitration": {"type": "boolean"}},
340
+ "required": ["paid", "ground"], "additionalProperties": False}},
341
+ {"name": "sud_cli_fee_indexation", "description": "Indexation frame (183/208/189.1) or compute with operator CPI.",
342
+ "schema": {"type": "object", "properties": {
343
+ "code": {"type": "string", "enum": ["apk", "gpk", "kas"]},
344
+ "amount": {"type": "string"}, "cpi_start": {"type": "string"}, "cpi_end": {"type": "string"}},
345
+ "required": ["code"], "additionalProperties": False}},
346
+ {"name": "sud_cli_pretrial_calc", "description": "Pretrial response deadline calc.",
347
+ "schema": {"type": "object", "properties": {
348
+ "code": {"type": "string", "enum": ["apk", "zozpp", "osago"]},
349
+ "sent": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"}},
350
+ "required": ["code", "sent"], "additionalProperties": False}},
351
+ {"name": "sud_cli_pretrial_consequences", "description": "Consequences of noncompliance with pretrial order.",
352
+ "schema": {"type": "object", "properties": {
353
+ "code": {"type": "string", "enum": ["apk", "gpk", "kas"]},
354
+ "stage": {"type": "string", "enum": ["filing", "trial", "movement"]}},
355
+ "required": ["code", "stage"], "additionalProperties": False}},
356
+
357
+ # ---------------- W6a §1/§3/§4/§5: паритет недостающих утилитных команд ----
358
+ # Live-сетевые команды в этой волне НЕ экспонированы: новые тулзы только
359
+ # локальные (state-dir + rules), сеть запрещена контрактом task-briefa.
360
+ {"name": "sud_cli_deadlines_anchor", "description": "Anchor deadline start date with evidence (write: state=computed).",
361
+ "schema": {"type": "object", "properties": {
362
+ "case_id": {"type": "string"},
363
+ "key": {"type": "string", "description": "Ключ из утверждённой матрицы сроков"},
364
+ "event_date": {**DATE},
365
+ "method": {"type": "string", "enum": ["вручение", "публикация", "почта", "иное"]},
366
+ "evidence": {"type": "string"},
367
+ "by": {"type": "string", "description": "Актор (по умолчанию 'mcp')"}},
368
+ "required": ["case_id", "key", "event_date", "method", "evidence"],
369
+ "additionalProperties": False}},
370
+ {"name": "sud_cli_deadlines_confirm", "description": "Confirm (or waive) a deadline state (write).",
371
+ "schema": {"type": "object", "properties": {
372
+ "case_id": {"type": "string"},
373
+ "key": {"type": "string"},
374
+ "by": {"type": "string"},
375
+ "evidence": {"type": "string"},
376
+ "waive": {"type": "boolean", "default": False},
377
+ "norm": {"type": "string", "description": "Норма-ссылка (обязательна при waive)"}},
378
+ "required": ["case_id", "key", "by"],
379
+ "additionalProperties": False}},
380
+ {"name": "sud_cli_deadlines_waive", "description": "Waive a deadline with norm reference (write).",
381
+ "schema": {"type": "object", "properties": {
382
+ "case_id": {"type": "string"},
383
+ "key": {"type": "string"},
384
+ "by": {"type": "string"},
385
+ "norm": {"type": "string"},
386
+ "evidence": {"type": "string"}},
387
+ "required": ["case_id", "key", "by", "norm"],
388
+ "additionalProperties": False}},
389
+ {"name": "sud_cli_deadlines_list", "description": "Deadline states history (optionally per case).",
390
+ "schema": {"type": "object", "properties": {
391
+ "case_id": {"type": "string", "description": "Идентификатор дела; без него — все состояния"}},
392
+ "additionalProperties": False}},
393
+ {"name": "sud_cli_deadlines_audit", "description": "Deadline edit audit log for a case.",
394
+ "schema": {"type": "object", "properties": {
395
+ "case_id": {"type": "string"}},
396
+ "required": ["case_id"], "additionalProperties": False}},
397
+ {"name": "sud_cli_deadlines_autofill", "description": "Autofill deadline points from act-text markers (write; computed+needs_confirm only, never confirmed).",
398
+ "schema": {"type": "object", "properties": {
399
+ "case_id": {"type": "string"},
400
+ "event": {"type": "string", "description": "Обработать только этот event_id"}},
401
+ "required": ["case_id"], "additionalProperties": False}},
402
+ {"name": "sud_cli_calendar_agenda", "description": "Unified agenda of deadlines and hearings.",
403
+ "schema": {"type": "object", "properties": {
404
+ "days": {"type": "integer", "default": 14, "minimum": 1, "maximum": 365}},
405
+ "additionalProperties": False}},
406
+ {"name": "sud_cli_calendar_ics", "description": "RFC 5545 VCALENDAR of computed/confirmed deadlines (text only; no file writes).",
407
+ "schema": {"type": "object", "properties": {
408
+ "days": {"type": "integer", "default": 60, "minimum": 1, "maximum": 3650}},
409
+ "additionalProperties": False}},
410
+ {"name": "sud_cli_reminders_due", "description": "Deadlines in window + ready bus-free a2a strings (sud never calls the bus).",
411
+ "schema": {"type": "object", "properties": {
412
+ "days": {"type": "integer", "default": 7, "minimum": 1, "maximum": 90}},
413
+ "additionalProperties": False}},
414
+ {"name": "sud_cli_case_search_alerts", "description": "Local case-search alerts -> bus-free a2a strings (offline artifacts only).",
415
+ "schema": {"type": "object", "properties": {
416
+ "days": {"type": "integer", "default": 7, "minimum": 1, "maximum": 90},
417
+ "alerts_dir": {"type": "string", "description": "Каталог alert-артефактов (по умолчанию — локальные пути)"}},
418
+ "additionalProperties": False}},
419
+ # the wave §4 (S-70): структурный скелет формы = чистое чтение реестра + строка;
420
+ # без сайд-эффектов => permission read (в WRITE_TOOLS не попадает).
421
+ {"name": "sud_cli_form_new", "description": "Структурный скелет процессуальной формы по делу (md|json, плейсхолдеры, без нормативных цитат; read).",
422
+ "schema": {"type": "object", "properties": {
423
+ "kind": {"type": "string", "enum": ["vosstanovlenie_sroka", "otsrochka", "appeal_structure"]},
424
+ "case_id": {"type": "string", "description": "Дело из локального реестра"},
425
+ "format": {"type": "string", "enum": ["md", "json"], "default": "md"}},
426
+ "required": ["kind", "case_id"], "additionalProperties": False}},
427
+ # ---------------- the wave §1..§5: портфель, данные, ПДн (12 тулов) ----------------
428
+ # Порядок read|write — по матрице ТЗ §5: только изменение состояния или
429
+ # запись на диск получает write+confirm; счёт/чтение остаётся read.
430
+ {"name": "sud_cli_deadlines_chain", "description": "Цепочка инстанций дела по ключам VERIFIED матрицы (read; без якоря — null+needs_anchor, дат не выдумываем).",
431
+ "schema": {"type": "object", "properties": {
432
+ "case_id": {"type": "string"},
433
+ "instance": {"type": "string", "enum": ["apk", "gpk", "kas"],
434
+ "description": "Override инстанции дела"}},
435
+ "required": ["case_id"], "additionalProperties": False}},
436
+ {"name": "sud_cli_case_rep_add", "description": "Добавить представителя дела (write; полномочия не проверяются — hint о ручной проверке в ответе).",
437
+ "schema": {"type": "object", "properties": {
438
+ "case_id": {"type": "string"},
439
+ "side": {"type": "string", "enum": ["plaintiff", "defendant"]},
440
+ "name": {"type": "string"},
441
+ "authority_note": {"type": "string"},
442
+ "power_docs": {"type": "string", "description": "Свободное описание доверенности"}},
443
+ "required": ["case_id", "side", "name"], "additionalProperties": False}},
444
+ {"name": "sud_cli_case_rep_list", "description": "Список представителей дела (read; пусто = []).",
445
+ "schema": {"type": "object", "properties": {"case_id": {"type": "string"}},
446
+ "required": ["case_id"], "additionalProperties": False}},
447
+ {"name": "sud_cli_calendar_conflicts", "description": "Пересечения слушаний (фикс. набор event_type) и сроков по разным делам (read; справочно).",
448
+ "schema": {"type": "object", "properties": {
449
+ "days": {"type": "integer", "default": 30, "minimum": 1, "maximum": 3650}},
450
+ "additionalProperties": False}},
451
+ {"name": "sud_cli_report_portfolio", "description": "Сводка портфеля: дела по статусам, near/overdue, суммы Decimal, представители (read; без выдуманных KPI).",
452
+ "schema": {"type": "object", "properties": {
453
+ "as_of": {"type": "string", "description": "Дата отсчёта YYYY-MM-DD"},
454
+ "days": {"type": "integer", "default": 30, "minimum": 1, "maximum": 3650}},
455
+ "additionalProperties": False}},
456
+ {"name": "sud_cli_report_case", "description": "Развёртка дела: секции card + сводка матрицы + evidence verify (read; offline-пересчёт локальных path).",
457
+ "schema": {"type": "object", "properties": {"case_id": {"type": "string"}},
458
+ "required": ["case_id"], "additionalProperties": False}},
459
+ {"name": "sud_cli_metrics", "description": "Метрики state-dir: счётчики из таблиц состояния, last backup из meta (read; тот же блок, что в doctor).",
460
+ "schema": {"type": "object", "properties": {
461
+ "near_days": {"type": "integer", "default": 30, "minimum": 1, "maximum": 3650,
462
+ "description": "Горизонт near для счётчика сроков"}},
463
+ "additionalProperties": False}},
464
+ {"name": "sud_cli_backup_create", "description": "Снять snapshot store.db (write: файл 0600 + sha256 sidecar на диске state-dir).",
465
+ "schema": {"type": "object", "properties": {
466
+ "out": {"type": "string", "description": "Путь снимка (по умолчанию state-dir/backups)"}},
467
+ "additionalProperties": False}},
468
+ {"name": "sud_cli_backup_verify", "description": "Проверить снимок: integrity_check + quick_check + таблицы схемы + sidecar (read).",
469
+ "schema": {"type": "object", "properties": {"file": {"type": "string"}},
470
+ "required": ["file"], "additionalProperties": False}},
471
+ {"name": "sud_cli_backup_restore", "description": "Перезаписать store.db из снимка (write+confirm: деструктив, без confirm — только превью).",
472
+ "schema": {"type": "object", "properties": {"file": {"type": "string"}},
473
+ "required": ["file"], "additionalProperties": False}},
474
+ {"name": "sud_cli_case_export", "description": "Экспорт дела для субъекта/юриста: полный дамп секций + pdn_policy дословно из правил (read; без сокращений).",
475
+ "schema": {"type": "object", "properties": {
476
+ "case_id": {"type": "string"},
477
+ "format": {"type": "string", "enum": ["json", "md"], "default": "json"}},
478
+ "required": ["case_id"], "additionalProperties": False}},
479
+ {"name": "sud_cli_case_purge", "description": "Уничтожить данные дела: каскад таблиц + файлы, в audit только факт без ПДн (write+confirm).",
480
+ "schema": {"type": "object", "properties": {
481
+ "case_id": {"type": "string"},
482
+ "remove_files": {"type": "boolean", "default": True}},
483
+ "required": ["case_id"], "additionalProperties": False}},
484
+ ]
485
+
486
+
487
+ # ---------------- W6a §1 (S-80): permission + идемпотентность ----------------
488
+ #: state-changing-тулы. Всё остальное — read. Поле permission дописывается
489
+ #: КАЖДОЙ схеме тула (включая исторические 35): один источник правды — этот
490
+ #: набор, а не 45 ручных строк, которые расходятся при первом же новом туле.
491
+ #: write-тулы дополнительно получают в properties поля confirm/idempotency_key:
492
+ #: additionalProperties:false обязывает объявлять всё, что диспатч принимает.
493
+ WRITE_TOOLS = frozenset({
494
+ # v2.15.0 S-61: внесение результата ручной проверки меняет реестр дела.
495
+ "sud_cli_fssp_record",
496
+ "sud_cli_case_new", "sud_cli_case_set", "sud_cli_case_amount_add",
497
+ "sud_cli_case_doc_add", "sud_cli_case_note_add",
498
+ "sud_cli_deadlines_anchor", "sud_cli_deadlines_confirm",
499
+ "sud_cli_deadlines_waive", "sud_cli_deadlines_autofill",
500
+ # the wave: запись представителя, снимок на диск, перезапись БД, уничтожение
501
+ # данных дела — всё это меняет состояние, значит write + confirm.
502
+ "sud_cli_case_rep_add", "sud_cli_backup_create",
503
+ "sud_cli_backup_restore", "sud_cli_case_purge",
504
+ })
505
+
506
+ CONFIRM_PROPERTY = {
507
+ "type": "boolean",
508
+ "description": "write-инструмент: без confirm=true вернёт status=needs_confirmation, запись не производится (W6a §1)",
509
+ }
510
+ IDEMPOTENCY_PROPERTY = {
511
+ "type": "string",
512
+ "description": "повтор с тем же ключом в окне 10 минут возвращает первый результат с флагом idempotent_replay=true (реестр state-dir/idempotency.json, 0600)",
513
+ }
514
+
515
+
516
+ def _apply_permission_fields() -> None:
517
+ for t in TOOLS:
518
+ t["permission"] = "write" if t["name"] in WRITE_TOOLS else "read"
519
+ if t["permission"] == "write":
520
+ props = t["schema"].setdefault("properties", {})
521
+ props.setdefault("confirm", dict(CONFIRM_PROPERTY))
522
+ props.setdefault("idempotency_key", dict(IDEMPOTENCY_PROPERTY))
523
+
524
+
525
+ _apply_permission_fields()
526
+
527
+
528
+ #: Паритет MCP↔CLI (S-80): инструмент отвечает позиционной команде CLI.
529
+ #: Тест сверяет белый список офлайн-утилит против дерева argparse, а также
530
+ #: то, что каждый тул имеет CLI-аналог (обратная строна паритета).
531
+ TOOL_CLI_PATH: dict[str, tuple] = {
532
+ "sud_cli_limitation": ("limitation",),
533
+ "sud_cli_deadlines": ("deadlines", "calculate"),
534
+ "sud_cli_deadlines_matrix": ("deadlines", "matrix"),
535
+ "sud_cli_deadlines_anchor": ("deadlines", "anchor"),
536
+ "sud_cli_deadlines_confirm": ("deadlines", "confirm"),
537
+ "sud_cli_deadlines_waive": ("deadlines", "waive"),
538
+ "sud_cli_deadlines_list": ("deadlines", "list"),
539
+ "sud_cli_deadlines_audit": ("deadlines", "audit"),
540
+ "sud_cli_deadlines_autofill": ("deadlines", "autofill"),
541
+ "sud_cli_fees": ("fees",),
542
+ "sud_cli_interest": ("interest",),
543
+ "sud_cli_practice_search": ("practice",),
544
+ "sud_cli_kad_search": ("kad",),
545
+ "sud_cli_timeline_audit": ("timeline-audit",),
546
+ "sud_cli_npa_as_of": ("npa-as-of",),
547
+ "sud_cli_verify_quote": ("verify", "quote"),
548
+ "sud_cli_case_history": ("case-history",),
549
+ "sud_cli_payment": ("payment",),
550
+ "sud_cli_deferral": ("deferral",),
551
+ "sud_cli_egrul_search": ("egrul",),
552
+ "sud_cli_fssp_check_plan": ("fssp", "check"),
553
+ "sud_cli_fssp_record": ("fssp", "record"),
554
+ "sud_cli_fssp_stats": ("fssp", "stats"),
555
+ "sud_cli_license_info": ("license",),
556
+ "sud_cli_efrsb_link": ("efrsb", "link"),
557
+ "sud_cli_plenum_search": ("plenum", "search"),
558
+ "sud_cli_efrsb_companies": ("efrsb", "companies"),
559
+ "sud_cli_efrsb_persons": ("efrsb", "persons"),
560
+ "sud_cli_efrsb_publications": ("efrsb", "publications"),
561
+ "sud_cli_efrsb_managers": ("efrsb", "managers"),
562
+ "sud_cli_sudrf_live": ("sudrf-live",),
563
+ "sud_cli_mos_live": ("mos-live",),
564
+ "sud_cli_case_diff": ("case-search", "case-diff"),
565
+ "sud_cli_npa_compare": ("npa", "compare"),
566
+ "sud_cli_case_new": ("case", "new"),
567
+ "sud_cli_case_set": ("case", "set"),
568
+ "sud_cli_case_list": ("case", "list"),
569
+ "sud_cli_case_card": ("case", "card"),
570
+ "sud_cli_case_amount_add": ("case", "amount", "add"),
571
+ "sud_cli_case_doc_add": ("case", "doc", "add"),
572
+ "sud_cli_case_note_add": ("case", "note", "add"),
573
+ "sud_cli_conflict_check": ("conflict", "check"),
574
+ "sud_cli_jurisdiction_check": ("jurisdiction", "check"),
575
+ "sud_cli_fee_exemption": ("fee-exemption",),
576
+ "sud_cli_fee_refund": ("fee-refund",),
577
+ "sud_cli_fee_indexation": ("fee-indexation",),
578
+ "sud_cli_pretrial_calc": ("pretrial", "calc"),
579
+ "sud_cli_pretrial_consequences": ("pretrial", "consequences"),
580
+ "sud_cli_calendar_agenda": ("calendar", "agenda"),
581
+ "sud_cli_calendar_ics": ("calendar", "ics"),
582
+ "sud_cli_reminders_due": ("reminders", "due"),
583
+ "sud_cli_case_search_alerts": ("case-search", "alerts"),
584
+ "sud_cli_form_new": ("form", "new"),
585
+ # the wave: каждый новый тул обязан иметь живую CLI-команду — паритет S-80.
586
+ "sud_cli_deadlines_chain": ("deadlines", "chain"),
587
+ "sud_cli_case_rep_add": ("case", "rep", "add"),
588
+ "sud_cli_case_rep_list": ("case", "rep", "list"),
589
+ "sud_cli_calendar_conflicts": ("calendar", "conflicts"),
590
+ "sud_cli_report_portfolio": ("report", "portfolio"),
591
+ "sud_cli_report_case": ("report", "case"),
592
+ "sud_cli_metrics": ("metrics",),
593
+ "sud_cli_backup_create": ("backup", "create"),
594
+ "sud_cli_backup_verify": ("backup", "verify"),
595
+ "sud_cli_backup_restore": ("backup", "restore"),
596
+ "sud_cli_case_export": ("case", "export"),
597
+ "sud_cli_case_purge": ("case", "purge"),
598
+ }
599
+
600
+ #: Сетевые live-команды (под гейтом SUD_CLI_BRIDGE_LIVE или native-live).
601
+ #: В новой волне ни один тул сюда не добавлен; существующие не тронуты.
602
+ LIVE_GATED_TOOLS = frozenset({
603
+ "sud_cli_practice_search", "sud_cli_kad_search", "sud_cli_case_history",
604
+ "sud_cli_efrsb_companies", "sud_cli_efrsb_persons",
605
+ "sud_cli_efrsb_publications", "sud_cli_efrsb_managers",
606
+ "sud_cli_sudrf_live", "sud_cli_mos_live",
607
+ })
608
+
609
+
610
+ def tool_permission(name: str) -> str | None:
611
+ for t in TOOLS:
612
+ if t["name"] == name:
613
+ return t["permission"]
614
+ return None
615
+
616
+
617
+ def mcp_exposed_commands() -> set:
618
+ """CLI-пути команд, закрытые MCP-инструментами (для паритетного теста)."""
619
+ return {TOOL_CLI_PATH[t["name"]] for t in TOOLS if t["name"] in TOOL_CLI_PATH}
620
+
621
+
622
+ def tool_list():
623
+ return {"tools": [{"name": t["name"], "description": t["description"],
624
+ "inputSchema": t["schema"], "permission": t["permission"]}
625
+ for t in TOOLS]}
626
+
627
+
628
+ def _pick(values: dict, names, label: str):
629
+ """Значение параметра под любым из допустимых имён (CLI-алиас ↔ имя движка).
630
+
631
+ Оба имени принимаются, но молча выбрать одно при конфликте нельзя: агент,
632
+ передавший `kind` и `fee_kind` с разными значениями, получил бы ответ по
633
+ другому основанию, чем просил. Конфликт — явная ошибка.
634
+ """
635
+ found = {name: values.pop(name) for name in names if name in values}
636
+ if not found:
637
+ return None
638
+ distinct = {repr(value) for value in found.values()}
639
+ if len(distinct) > 1:
640
+ raise ValueError(
641
+ f"conflicting values for {label}: {sorted(found)} — передайте только одно имя"
642
+ )
643
+ return next(iter(found.values()))
644
+
645
+
646
+ def _claim_amount(value):
647
+ """Цена иска из JSON в денежный тип.
648
+
649
+ JSON `number` прибыл float'ом — его надо перевести в Decimal через str,
650
+ иначе двоичная погрешность осядет в копейках. bool — не деньги.
651
+ """
652
+ if value is None:
653
+ return None
654
+ if isinstance(value, bool):
655
+ raise ValueError("claim_amount must be a number of rubles, not a boolean")
656
+ if isinstance(value, Decimal):
657
+ amount = value
658
+ elif isinstance(value, int):
659
+ amount = value
660
+ elif isinstance(value, float):
661
+ amount = Decimal(str(value))
662
+ elif isinstance(value, str):
663
+ from decimal import InvalidOperation
664
+ try:
665
+ amount = Decimal(value.strip())
666
+ except InvalidOperation:
667
+ raise ValueError(f"claim_amount must be a number of rubles, got {value!r}") from None
668
+ else:
669
+ raise ValueError(f"claim_amount must be a number of rubles, got {type(value).__name__}")
670
+ if isinstance(amount, Decimal) and not amount.is_finite():
671
+ raise ValueError(f"claim_amount must be finite, got {amount}")
672
+ return amount
673
+
674
+
675
+ def _fees_arguments(args: dict) -> dict:
676
+ """Нормализация вызова sud_cli_fees: алиасы имён + денежный тип базы."""
677
+ from .legal_engine import parse_as_of
678
+
679
+ values = dict(args)
680
+ jurisdiction = _pick(values, ("jurisdiction",), "jurisdiction")
681
+ if jurisdiction is None:
682
+ raise ValueError("jurisdiction is required (arbitration|general_jurisdiction|kas)")
683
+ key = str(jurisdiction).strip().lower()
684
+ jurisdiction = JURISDICTION_ALIASES.get(key, key)
685
+ fee_kind = _pick(values, ("fee_kind", "kind"), "fee_kind/kind")
686
+ if fee_kind is None:
687
+ raise ValueError(
688
+ "fee_kind is required; accepted names: fee_kind (engine) or kind (CLI --kind)"
689
+ )
690
+ applicant_type = _pick(values, ("applicant_type", "applicant"), "applicant_type/applicant")
691
+ claim_amount = _claim_amount(values.pop("claim_amount", None))
692
+ claim_class = values.pop("claim_class", None)
693
+ # v2.10.0 (W4d): дата-привязка тарифа. Значение отсутствует → None → прежний
694
+ # контракт (действующая редакция, конверт без provenance). Неверная строка —
695
+ # явная ошибка, а не молчаливый пересчёт «на сегодня».
696
+ as_of = parse_as_of(values["as_of"]) if values.get("as_of") is not None else None
697
+ values.pop("as_of", None)
698
+ if values:
699
+ raise TypeError(f"unknown fees arguments: {sorted(values)}")
700
+ return {"jurisdiction": jurisdiction, "fee_kind": fee_kind,
701
+ "applicant_type": applicant_type, "claim_amount": claim_amount,
702
+ "claim_class": claim_class, "as_of": as_of}
703
+
704
+
705
+ def _mcp_state_dir():
706
+ import os
707
+ from pathlib import Path
708
+ return Path(os.environ.get("SUD_CLI_STATE_DIR") or (Path.home() / ".local" / "share" / "sud-cli"))
709
+
710
+
711
+ def _call(name, args):
712
+ if name == "sud_cli_efrsb_link":
713
+ from . import efrsb_manual
714
+ return efrsb_manual.link_plan(args["kind"], name=args.get("name"),
715
+ inn=args.get("inn"), ogrn=args.get("ogrn"))
716
+ if name == "sud_cli_plenum_search":
717
+ from . import plenum_corpus as pc
718
+ from .config import get_state_dir
719
+ return pc.search(get_state_dir(), args["text"])
720
+ if name == "sud_cli_license_info":
721
+ from . import _wm
722
+ from . import __version__ as _v
723
+ payload = _wm.source_info(_v)
724
+ payload["build_flags"] = _wm.build_flags(_v) if args.get("with_build_flags", True) else []
725
+ return payload
726
+ if name == "sud_cli_limitation":
727
+ from .limitation import calculate_limitation
728
+ values = dict(args)
729
+ picked = _pick(values, ("period_type", "type"), "period_type/type")
730
+ values["period_type"] = picked if picked is not None else "general"
731
+ for engine_name, cli_name in (("custom_years", "years"), ("custom_months", "months")):
732
+ chosen = _pick(values, (engine_name, cli_name), f"{engine_name}/{cli_name}")
733
+ if chosen is not None:
734
+ values[engine_name] = chosen
735
+ values["start"] = date.fromisoformat(values["start"])
736
+ if values.get("check_date"):
737
+ values["check_date"] = date.fromisoformat(values["check_date"])
738
+ return calculate_limitation(**values).to_dict()
739
+ if name == "sud_cli_timeline_audit":
740
+ from .timeline_audit import audit_timeline
741
+ return audit_timeline(args.get("events", []))
742
+ if name == "sud_cli_fee_exemption":
743
+ from .fees_extra import fee_exemption_check
744
+ return fee_exemption_check(load_rules(), jurisdiction=args["jurisdiction"], category_code=args["category"])
745
+ if name == "sud_cli_fee_refund":
746
+ from decimal import Decimal
747
+ from .fees_extra import fee_refund
748
+ return fee_refund(load_rules(), paid=Decimal(args["paid"]), ground=args["ground"],
749
+ claim_admitted=bool(args.get("claim_admitted")),
750
+ plaintiff_default=bool(args.get("plaintiff_default")),
751
+ transferred_to_arbitration=bool(args.get("transferred_to_arbitration")))
752
+ if name == "sud_cli_fee_indexation":
753
+ from decimal import Decimal
754
+ from .fees_extra import indexation_compute, indexation_frame
755
+ r = load_rules()
756
+ if args.get("amount"):
757
+ return indexation_compute(r, code=args["code"], amount=Decimal(args["amount"]),
758
+ cpi_start=Decimal(args["cpi_start"]) if args.get("cpi_start") else None,
759
+ cpi_end=Decimal(args["cpi_end"]) if args.get("cpi_end") else None)
760
+ return indexation_frame(r, code=args["code"])
761
+ if name == "sud_cli_npa_as_of":
762
+ from .npa_as_of import lookup_as_of
763
+ return lookup_as_of(args["act"], args["article"], args["date"])
764
+ if name == "sud_cli_verify_quote":
765
+ from .verify import verify_quote
766
+ values = dict(args or {})
767
+ return verify_quote(
768
+ values["text"], values["source"],
769
+ act=values.get("act"), article=values.get("article"), date=values.get("date"),
770
+ case_id=values.get("case_id"), path=values.get("path"),
771
+ state_dir=_mcp_state_dir(), max_fuzzy=values.get("max_fuzzy", 0.0))
772
+ if name == "sud_cli_deadlines":
773
+ from .legal_deadlines import calculate_deadline
774
+ allowed = {"deadline_key", "event_date", "region", "tz"}
775
+ values = {k: v for k, v in (args or {}).items() if k in allowed}
776
+ if values.get("event_date"):
777
+ values["event_date"] = date.fromisoformat(values["event_date"])
778
+ return calculate_deadline(**values).to_dict()
779
+ if name == "sud_cli_deadlines_matrix":
780
+ from .deadline_matrix import build_matrix
781
+ from .legal_engine import load_rules
782
+ values = dict(args or {})
783
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
784
+ if case_id is None:
785
+ raise ValueError("case_id is required")
786
+ instance_override = values.pop("instance", None)
787
+ as_of = values.pop("as_of", None)
788
+ missing = bool(values.pop("missing", False))
789
+ if values:
790
+ raise ValueError(f"unknown fields: {sorted(values)}")
791
+ state_dir = _mcp_state_dir()
792
+ instance, points = build_matrix(state_dir, case_id, instance_override=instance_override, as_of=as_of)
793
+ if missing:
794
+ points = [p for p in points if p.get("needs_anchor")]
795
+ rules = load_rules()
796
+ calc_date = date.today().isoformat()
797
+ disclaimer = rules["mandatory_disclaimer"].format(calc_date=calc_date)
798
+ payload = {
799
+ "case_id": case_id,
800
+ "instance": instance,
801
+ "as_of": as_of,
802
+ "points": points,
803
+ "matrix": points,
804
+ "items": points,
805
+ "deadlines": points,
806
+ "entries": points,
807
+ "disclaimer": disclaimer,
808
+ "clean_room": rules.get("clean_room"),
809
+ }
810
+ return payload
811
+ if name == "sud_cli_fees":
812
+ from .legal_engine import STATUS_OK, calculate_fee, load_rules, validate_claim_class
813
+ # v2.3.1: вызов проходит через нормализацию — CLI-алиасы (kind, applicant),
814
+ # алиасы юрисдикции и цена иска в денежном типе (Decimal с копейками).
815
+ rules = load_rules()
816
+ fa = _fees_arguments(args)
817
+ cc = fa.get("claim_class")
818
+ if cc is not None:
819
+ # v2.3.3 (review R2): тот же guard класса, что CLI; без него
820
+ # claim_class="criminal" молча проходил в расчёт через MCP.
821
+ cr = validate_claim_class(cc, rules, fa.get("fee_kind"))
822
+ if cr.status != STATUS_OK:
823
+ return cr.to_dict()
824
+ return calculate_fee(rules=rules, **fa).to_dict()
825
+ if name == "sud_cli_interest":
826
+ from .interest import calculate_interest
827
+ values = dict(args)
828
+ # start/start_date и end/end_date — одно и то же разными именами (CLI
829
+ # десты start_date/end_date). Конфликт значений молча проигрывает одной
830
+ # стороне, поэтому тоже явная ошибка.
831
+ start_value = _pick(values, ("start_date", "start"), "start/start_date")
832
+ end_value = _pick(values, ("end_date", "end"), "end/end_date")
833
+ if start_value is None or end_value is None:
834
+ raise ValueError("start and end are required (или CLI-алиасы start_date/end_date)")
835
+ values["start"] = date.fromisoformat(start_value)
836
+ values["end"] = date.fromisoformat(end_value)
837
+ return calculate_interest(**values).to_dict()
838
+ if name in {"sud_cli_practice_search", "sud_cli_kad_search", "sud_cli_case_history"}:
839
+ # v2.3.2 (sa-1 F-05): живые поиски под тем же гейтом SUD_CLI_BRIDGE_LIVE,
840
+ # что и sudrf_live/mos_live — без него никаких исходящих запросов в суды.
841
+ # v2.3.3 (review R1): строго {1,true,yes,on} как в bridge — "0"/"off" раньше
842
+ # ОТКРЫВАЛИ live-доступ (truthy-строка). Единый контракт обоих входов.
843
+ if os.environ.get("SUD_CLI_BRIDGE_LIVE", "").strip().lower() not in {"1", "true", "yes", "on"}:
844
+ return {"status": "blocked", "reason": "live_access_disabled",
845
+ "hint": "Исходящие запросы к sudact включаются переменной SUD_CLI_BRIDGE_LIVE."}
846
+ if name == "sud_cli_practice_search":
847
+ from .case_search.public_api import search_practice
848
+ return search_practice(**args)
849
+ if name == "sud_cli_kad_search":
850
+ from .case_search.public_api import kad_search
851
+ return kad_search(**args)
852
+ if name == "sud_cli_case_history":
853
+ from .case_search.public_api import case_history
854
+ values = dict(args)
855
+ # CLI отдаёт номер дела позиционным аргументом (dest case_id); MCP-схема
856
+ # исторически звала его case_number. Оба имени принимаются, конфликт — ошибка.
857
+ chosen = _pick(values, ("case_number", "case_id"), "case_number/case_id")
858
+ if chosen is None:
859
+ raise ValueError("case_number is required; accepted names: case_number or case_id")
860
+ values["case_number"] = chosen
861
+ return case_history(**values)
862
+ if name == "sud_cli_payment":
863
+ from .payment import payment_details
864
+ return payment_details(**args)
865
+ if name == "sud_cli_deferral":
866
+ from .deferral import generate_deferral
867
+ return generate_deferral(**args)
868
+ if name == "sud_cli_fssp_check_plan":
869
+ from .fssp_manual import check_plan
870
+ values = dict(args or {})
871
+ kind = values.pop("kind", None)
872
+ if kind is None:
873
+ raise ValueError("kind is required")
874
+ kwargs = {}
875
+ for k in ("name", "inn", "ogrn", "birth_date", "region"):
876
+ if k in values:
877
+ kwargs[k] = values.pop(k)
878
+ if values:
879
+ raise ValueError(f"unknown fields: {sorted(values)}")
880
+ return check_plan(kind, **kwargs)
881
+ if name == "sud_cli_fssp_record":
882
+ from .db import add_case_evidence, get_connection, init_db
883
+ from .fssp_manual import record_payload
884
+ values = dict(args or {})
885
+ case_id = values.pop("case_id", None)
886
+ subject = values.pop("subject", None)
887
+ result_v = values.pop("result", None)
888
+ checked_by = values.pop("checked_by", None)
889
+ if not (case_id and subject and result_v and checked_by):
890
+ raise ValueError("case_id, subject, result, checked_by are required")
891
+ payload = record_payload(subject=subject, result=result_v,
892
+ checked_by=checked_by,
893
+ notes=values.pop("notes", None),
894
+ evidence_file=values.pop("file", None))
895
+ if values:
896
+ raise ValueError(f"unknown fields: {sorted(values)}")
897
+ state_dir = _mcp_state_dir()
898
+ init_db(state_dir)
899
+ conn = get_connection(state_dir)
900
+ try:
901
+ return add_case_evidence(conn, case_id, title=payload["title"],
902
+ path=payload["evidence_file"], note=payload["note"])
903
+ finally:
904
+ conn.close()
905
+ if name == "sud_cli_fssp_stats":
906
+ from .fssp_stats import statistics
907
+ values = dict(args or {})
908
+ region = values.pop("region", None)
909
+ if values:
910
+ raise ValueError(f"unknown fields: {sorted(values)}")
911
+ return statistics(region)
912
+ if name == "sud_cli_efrsb_companies":
913
+ from .efrsb_public import search_companies
914
+ return search_companies(**args)
915
+ if name == "sud_cli_efrsb_persons":
916
+ from .efrsb_public import search_persons
917
+ values = dict(args)
918
+ # CLI-dest --name против имени движка full_name.
919
+ chosen = _pick(values, ("full_name", "name"), "full_name/name")
920
+ if chosen is None:
921
+ raise ValueError("full_name is required; accepted names: full_name or name")
922
+ values["full_name"] = chosen
923
+ return search_persons(**values)
924
+ if name == "sud_cli_efrsb_publications":
925
+ from .efrsb_public import case_publications
926
+ return case_publications(**args)
927
+ if name == "sud_cli_efrsb_managers":
928
+ from .efrsb_public import search_managers
929
+ return search_managers(**args)
930
+ if name in {"sud_cli_sudrf_live", "sud_cli_mos_live"}:
931
+ from .case_search_bridge import run_bridge_command
932
+ state_dir = Path(os.environ.get("SUD_CLI_STATE_DIR") or Path.home() / ".local" / "share" / "sud-cli")
933
+ kwargs = {"case_id": args["case_id"]}
934
+ if name == "sud_cli_mos_live":
935
+ kwargs["court_slug"] = args.get("court_slug")
936
+ return run_bridge_command("mos-live" if name.endswith("mos_live") else "sudrf-live",
937
+ state_dir=state_dir, **kwargs)
938
+ if name == "sud_cli_case_diff":
939
+ from .case_diff import DiffError, diff_case_snapshots
940
+ old = args.get("old") or args.get("old_snapshot")
941
+ new = args.get("new") or args.get("new_snapshot")
942
+ if not old:
943
+ raise ValueError("old snapshot path required")
944
+ if not new:
945
+ raise ValueError("new snapshot path required")
946
+ try:
947
+ return diff_case_snapshots(Path(old), Path(new))
948
+ except DiffError as exc:
949
+ # DiffError outside the handle_request catch set: convert, or the
950
+ # serve_stdio loop dies instead of returning isError=True.
951
+ raise ValueError(str(exc)) from exc
952
+ if name == "sud_cli_npa_compare":
953
+ from .db import SearchError, compare_npa_editions, get_connection, init_db
954
+ state_dir = Path(os.environ.get("SUD_CLI_STATE_DIR") or Path.home() / ".local" / "share" / "sud-cli")
955
+ init_db(state_dir)
956
+ conn = get_connection(state_dir)
957
+ try:
958
+ return compare_npa_editions(conn, args["doc_id"], args["edition1"], args["edition2"])
959
+ except SearchError as exc:
960
+ raise ValueError(str(exc)) from exc
961
+ finally:
962
+ conn.close()
963
+ if name == "sud_cli_case_new":
964
+ from .db import LEGAL_CASE_STATUSES, create_case, get_connection, init_db
965
+ state_dir = _mcp_state_dir()
966
+ init_db(state_dir)
967
+ conn = get_connection(state_dir)
968
+ try:
969
+ values = dict(args)
970
+ number = _pick(values, ("number", "case_number"), "number/case_number")
971
+ if number is None:
972
+ raise ValueError("number is required")
973
+ status = values.get("status")
974
+ if status is not None and status not in LEGAL_CASE_STATUSES:
975
+ raise ValueError("invalid_status")
976
+ res = create_case(
977
+ conn,
978
+ number,
979
+ values.get("court"),
980
+ values.get("plaintiff"),
981
+ values.get("defendant"),
982
+ owner=values.get("owner"),
983
+ status=status,
984
+ )
985
+ if res.get("duplicate"):
986
+ return {"status": "duplicate_window", "case_id": res["case_id"]}
987
+ return {"case_id": res["case_id"], "case_number": res.get("case_number"),
988
+ "court": res.get("court"), "status": res.get("status"),
989
+ "owner": res.get("owner")}
990
+ finally:
991
+ conn.close()
992
+ if name == "sud_cli_case_set":
993
+ from .db import LEGAL_CASE_STATUSES, get_connection, init_db, set_case
994
+ values = dict(args)
995
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
996
+ if case_id is None:
997
+ raise ValueError("case_id is required")
998
+ fields = {}
999
+ for k in ("status", "owner", "closed_at"):
1000
+ if k in values and values.get(k) is not None:
1001
+ fields[k] = values.pop(k)
1002
+ if values:
1003
+ raise ValueError(f"unknown fields: {sorted(values)}")
1004
+ if "status" in fields and fields["status"] not in LEGAL_CASE_STATUSES:
1005
+ raise ValueError("invalid_status")
1006
+ state_dir = _mcp_state_dir()
1007
+ init_db(state_dir)
1008
+ conn = get_connection(state_dir)
1009
+ try:
1010
+ updated = set_case(conn, case_id, **fields)
1011
+ finally:
1012
+ conn.close()
1013
+ if updated is None:
1014
+ raise ValueError(f"case not found: {case_id}")
1015
+ return updated
1016
+ if name == "sud_cli_case_list":
1017
+ from .db import get_connection, init_db, list_cases
1018
+ state_dir = _mcp_state_dir()
1019
+ init_db(state_dir)
1020
+ conn = get_connection(state_dir)
1021
+ try:
1022
+ return list_cases(
1023
+ conn,
1024
+ status=args.get("status"),
1025
+ owner=args.get("owner"),
1026
+ court=args.get("court"),
1027
+ party=args.get("party"),
1028
+ )
1029
+ finally:
1030
+ conn.close()
1031
+ if name == "sud_cli_case_card":
1032
+ from .db import get_case_card, get_connection, init_db
1033
+ values = dict(args)
1034
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1035
+ if case_id is None:
1036
+ raise ValueError("case_id is required")
1037
+ state_dir = _mcp_state_dir()
1038
+ init_db(state_dir)
1039
+ conn = get_connection(state_dir)
1040
+ try:
1041
+ card = get_case_card(conn, case_id)
1042
+ finally:
1043
+ conn.close()
1044
+ if card is None:
1045
+ raise ValueError(f"case not found: {case_id}")
1046
+ return card
1047
+ if name == "sud_cli_case_amount_add":
1048
+ from .db import InvalidAmountError, add_case_amount, get_connection, init_db
1049
+ values = dict(args)
1050
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1051
+ if case_id is None:
1052
+ raise ValueError("case_id is required")
1053
+ state_dir = _mcp_state_dir()
1054
+ init_db(state_dir)
1055
+ conn = get_connection(state_dir)
1056
+ try:
1057
+ try:
1058
+ res = add_case_amount(
1059
+ conn, case_id, values.get("kind"), values.get("amount"), note=values.get("note")
1060
+ )
1061
+ except InvalidAmountError as exc:
1062
+ raise ValueError("invalid_amount") from exc
1063
+ finally:
1064
+ conn.close()
1065
+ if res is None:
1066
+ raise ValueError(f"case not found: {case_id}")
1067
+ return res
1068
+ if name == "sud_cli_case_doc_add":
1069
+ from .db import DocNotFoundError, add_case_document, get_connection, init_db
1070
+ values = dict(args)
1071
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1072
+ if case_id is None:
1073
+ raise ValueError("case_id is required")
1074
+ state_dir = _mcp_state_dir()
1075
+ init_db(state_dir)
1076
+ conn = get_connection(state_dir)
1077
+ try:
1078
+ try:
1079
+ res = add_case_document(conn, case_id, values.get("path"), title=values.get("title"))
1080
+ except DocNotFoundError as exc:
1081
+ raise ValueError("doc_not_found") from exc
1082
+ finally:
1083
+ conn.close()
1084
+ if res is None:
1085
+ raise ValueError(f"case not found: {case_id}")
1086
+ return res
1087
+ if name == "sud_cli_case_note_add":
1088
+ from .db import add_case_note, get_connection, init_db
1089
+ values = dict(args)
1090
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1091
+ if case_id is None:
1092
+ raise ValueError("case_id is required")
1093
+ state_dir = _mcp_state_dir()
1094
+ init_db(state_dir)
1095
+ conn = get_connection(state_dir)
1096
+ try:
1097
+ res = add_case_note(conn, case_id, values.get("text"), author=values.get("author"))
1098
+ finally:
1099
+ conn.close()
1100
+ if res is None:
1101
+ raise ValueError(f"case not found: {case_id}")
1102
+ return res
1103
+ if name == "sud_cli_conflict_check":
1104
+ from .conflict import check as conflict_check
1105
+ from .db import get_case, get_connection, init_db, list_cases
1106
+
1107
+ values = dict(args)
1108
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1109
+ state_dir = _mcp_state_dir()
1110
+ init_db(state_dir)
1111
+ conn = get_connection(state_dir)
1112
+ try:
1113
+ if case_id is not None:
1114
+ case = get_case(conn, case_id)
1115
+ if case is None:
1116
+ raise ValueError(f"case not found: {case_id}")
1117
+ portfolio = [c for c in list_cases(conn) if c.get("case_id") != case_id]
1118
+ candidate = {
1119
+ "parties": [],
1120
+ "case_id": case_id,
1121
+ "court": case.get("court"),
1122
+ "case_number": case.get("case_number"),
1123
+ }
1124
+ for key in ("plaintiff", "defendant"):
1125
+ val = case.get(key)
1126
+ if val:
1127
+ candidate["parties"].append(val)
1128
+ else:
1129
+ portfolio = list_cases(conn)
1130
+ candidate = {
1131
+ "parties": list(values.pop("parties", []) or []),
1132
+ "court": values.pop("court", None),
1133
+ "case_number": values.pop("case_number", None),
1134
+ }
1135
+ for key in ("plaintiff", "defendant"):
1136
+ if key in values and values.get(key) is not None:
1137
+ candidate["parties"].append(values.pop(key))
1138
+ if values:
1139
+ raise ValueError(f"unknown fields: {sorted(values)}")
1140
+ hits = conflict_check(portfolio, candidate)
1141
+ finally:
1142
+ conn.close()
1143
+ return {
1144
+ "clean": len(hits) == 0,
1145
+ "hits": hits,
1146
+ "method": "name_exact",
1147
+ "coverage": "local registry names only",
1148
+ "disclaimer": (
1149
+ "Отсутствие совпадений не означает отсутствие конфликта: покрытие — "
1150
+ "только имена локального реестра; внешние стороны и аффилированные лица "
1151
+ "не охвачены. Решение остаётся за человеком (boundary IX-3)."
1152
+ ),
1153
+ }
1154
+ if name == "sud_cli_jurisdiction_check":
1155
+ from .jurisdiction import check_jurisdiction
1156
+ return check_jurisdiction(
1157
+ code=args.get("code"),
1158
+ claim=args.get("claim"),
1159
+ party_org=bool(args.get("party_org", False)),
1160
+ party_phys=bool(args.get("party_phys", False)),
1161
+ unknown_rf_location=bool(args.get("unknown_rf_location", False)),
1162
+ immovable=bool(args.get("immovable", False)),
1163
+ corporate=bool(args.get("corporate", False)),
1164
+ bankruptcy=bool(args.get("bankruptcy", False)),
1165
+ ip_rights=bool(args.get("ip_rights", False)),
1166
+ contract_forum=bool(args.get("contract_forum", False)),
1167
+ arbitration_clause=bool(args.get("arbitration_clause", False)),
1168
+ multiple_defendants=bool(args.get("multiple_defendants", False)),
1169
+ foreign_element=bool(args.get("foreign_element", False)),
1170
+ consumer=bool(args.get("consumer", False)),
1171
+ alimony=bool(args.get("alimony", False)),
1172
+ )
1173
+ if name == "sud_cli_pretrial_calc":
1174
+ from .pretrial import calc_pretrial
1175
+ return calc_pretrial(code=args.get("code"), sent=args.get("sent"))
1176
+ if name == "sud_cli_pretrial_consequences":
1177
+ from .pretrial import consequences
1178
+ return consequences(code=args.get("code"), stage=args.get("stage"))
1179
+ # ---------------- W6a §1/§3/§4/§5: диспатч новых офлайн-утилит -------------
1180
+ if name in {"sud_cli_deadlines_anchor", "sud_cli_deadlines_confirm",
1181
+ "sud_cli_deadlines_waive", "sud_cli_deadlines_autofill"}:
1182
+ from .db import (
1183
+ anchor_deadline_state,
1184
+ confirm_deadline_state,
1185
+ get_connection,
1186
+ init_db,
1187
+ waive_deadline_state,
1188
+ )
1189
+ values = dict(args or {})
1190
+ state_dir = _mcp_state_dir()
1191
+ init_db(state_dir)
1192
+ conn = get_connection(state_dir)
1193
+ try:
1194
+ case_id = values.pop("case_id", None)
1195
+ if case_id is None:
1196
+ raise ValueError("case_id is required")
1197
+ if name == "sud_cli_deadlines_anchor":
1198
+ key = values.pop("key", None)
1199
+ event_date = values.pop("event_date", None)
1200
+ method = values.pop("method", None)
1201
+ evidence = values.pop("evidence", None)
1202
+ actor = values.pop("by", None) or "mcp"
1203
+ if values:
1204
+ raise ValueError(f"unknown fields: {sorted(values)}")
1205
+ if not (key and event_date and method and evidence):
1206
+ raise ValueError("key, event_date, method, evidence are required")
1207
+ return anchor_deadline_state(conn, state_dir, case_id, key,
1208
+ event_date, method, evidence, actor=actor)
1209
+ if name == "sud_cli_deadlines_confirm":
1210
+ key = values.pop("key", None)
1211
+ by = values.pop("by", None)
1212
+ evidence = values.pop("evidence", None)
1213
+ waive = bool(values.pop("waive", False))
1214
+ norm = values.pop("norm", None)
1215
+ if values:
1216
+ raise ValueError(f"unknown fields: {sorted(values)}")
1217
+ if not (key and by):
1218
+ raise ValueError("key and by are required")
1219
+ if waive and not norm:
1220
+ raise ValueError("norm required for waive")
1221
+ return confirm_deadline_state(conn, state_dir, case_id, key, by,
1222
+ evidence=evidence, waive=waive, norm=norm)
1223
+ if name == "sud_cli_deadlines_waive":
1224
+ key = values.pop("key", None)
1225
+ by = values.pop("by", None)
1226
+ norm = values.pop("norm", None)
1227
+ evidence = values.pop("evidence", None)
1228
+ if values:
1229
+ raise ValueError(f"unknown fields: {sorted(values)}")
1230
+ if not (key and by and norm):
1231
+ raise ValueError("key, by and norm are required")
1232
+ return waive_deadline_state(conn, state_dir, case_id, key, by, norm,
1233
+ evidence=evidence)
1234
+ event = values.pop("event", None)
1235
+ if values:
1236
+ raise ValueError(f"unknown fields: {sorted(values)}")
1237
+ from .autofill import autofill_case
1238
+ return autofill_case(conn, state_dir, case_id, event_id=event)
1239
+ finally:
1240
+ conn.close()
1241
+ if name == "sud_cli_deadlines_list":
1242
+ from .db import get_connection, init_db, list_all_deadline_states, list_deadline_states
1243
+ state_dir = _mcp_state_dir()
1244
+ init_db(state_dir)
1245
+ conn = get_connection(state_dir)
1246
+ try:
1247
+ case_id = (args or {}).get("case_id")
1248
+ if case_id is not None:
1249
+ return list_deadline_states(conn, case_id)
1250
+ return list_all_deadline_states(conn)
1251
+ finally:
1252
+ conn.close()
1253
+ if name == "sud_cli_deadlines_audit":
1254
+ from .db import init_db, read_deadline_audit
1255
+ state_dir = _mcp_state_dir()
1256
+ init_db(state_dir)
1257
+ return read_deadline_audit(state_dir, args["case_id"])
1258
+ if name == "sud_cli_calendar_agenda":
1259
+ from .agenda import agenda_payload
1260
+ days = int((args or {}).get("days", 14))
1261
+ if not 1 <= days <= 365:
1262
+ raise ValueError("days must be within 1..365")
1263
+ return agenda_payload(_mcp_state_dir(), days=days)
1264
+ if name == "sud_cli_calendar_ics":
1265
+ from .calendar_ics import ics_for_state_dir
1266
+ days = int((args or {}).get("days", 60))
1267
+ if not 1 <= days <= 3650:
1268
+ raise ValueError("days must be within 1..3650")
1269
+ text, events = ics_for_state_dir(_mcp_state_dir(), days=days)
1270
+ return {"days": days, "count": len(events), "events": events, "ics": text}
1271
+ if name == "sud_cli_reminders_due":
1272
+ from .db import get_connection, init_db, list_all_deadline_states
1273
+ from .reminders import BUS_FREE_NOTE, due_items, generate_mail_commands
1274
+ days = int((args or {}).get("days", 7))
1275
+ if not 1 <= days <= 90:
1276
+ raise ValueError("days must be within 1..90")
1277
+ state_dir = _mcp_state_dir()
1278
+ init_db(state_dir)
1279
+ conn = get_connection(state_dir)
1280
+ try:
1281
+ states = list_all_deadline_states(conn)
1282
+ finally:
1283
+ conn.close()
1284
+ today = date.today()
1285
+ items = due_items(states, today, days)
1286
+ return {
1287
+ "days": days,
1288
+ "today": today.isoformat(),
1289
+ "window_to": (today + timedelta(days=days)).isoformat(),
1290
+ "items": items,
1291
+ "commands": generate_mail_commands(items, days),
1292
+ "bus_free": BUS_FREE_NOTE,
1293
+ }
1294
+ if name == "sud_cli_case_search_alerts":
1295
+ from . import court_alerts
1296
+ values = dict(args or {})
1297
+ days = int(values.pop("days", 7))
1298
+ if not 1 <= days <= 90:
1299
+ raise ValueError("days must be within 1..90")
1300
+ alerts_dir = values.pop("alerts_dir", None)
1301
+ if values:
1302
+ raise ValueError(f"unknown fields: {sorted(values)}")
1303
+ if alerts_dir is None:
1304
+ candidates = court_alerts.alerts_search_dirs(_mcp_state_dir())
1305
+ alerts_dir = next((d for d in candidates if d.is_dir()), candidates[0])
1306
+ return court_alerts.alerts_payload(alerts_dir, date.today(), days)
1307
+ if name == "sud_cli_form_new":
1308
+ # the wave §4: read-тул — только чтение реестра + детерминированный скелет.
1309
+ from .db import get_case, get_connection, init_db
1310
+ from .forms import render_form
1311
+ values = dict(args or {})
1312
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1313
+ kind = values.pop("kind", None)
1314
+ fmt = values.pop("format", "md")
1315
+ values.pop("case_id", None)
1316
+ values.pop("id", None)
1317
+ if values:
1318
+ raise ValueError(f"unknown fields: {sorted(values)}")
1319
+ if case_id is None:
1320
+ raise ValueError("case_id is required")
1321
+ state_dir = _mcp_state_dir()
1322
+ init_db(state_dir)
1323
+ conn = get_connection(state_dir)
1324
+ try:
1325
+ case = get_case(conn, case_id)
1326
+ finally:
1327
+ conn.close()
1328
+ if case is None:
1329
+ raise ValueError(f"case not found: {case_id}")
1330
+ return render_form(kind, case, fmt=fmt)
1331
+ # ---------------- the wave §1..§5 ----------------
1332
+ # Каждый тул вызывает ТОТ ЖЕ движок, что и CLI-команда из TOOL_CLI_PATH:
1333
+ # вторая копия логики в MCP расходится с оригиналом при первой же правке.
1334
+ if name == "sud_cli_deadlines_chain":
1335
+ from .chain import build_chain
1336
+ values = dict(args or {})
1337
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1338
+ instance = values.pop("instance", None)
1339
+ values.pop("case_id", None)
1340
+ values.pop("id", None)
1341
+ if values:
1342
+ raise ValueError(f"unknown fields: {sorted(values)}")
1343
+ if case_id is None:
1344
+ raise ValueError("case_id is required")
1345
+ return build_chain(_mcp_state_dir(), case_id, instance_override=instance)
1346
+ if name == "sud_cli_case_rep_add":
1347
+ from .db import add_case_representative, get_connection, init_db
1348
+ values = dict(args or {})
1349
+ case_id = values.pop("case_id", None)
1350
+ side = values.pop("side", None)
1351
+ rep_name = values.pop("name", None)
1352
+ authority_note = values.pop("authority_note", None)
1353
+ power_docs = values.pop("power_docs", None)
1354
+ if values:
1355
+ raise ValueError(f"unknown fields: {sorted(values)}")
1356
+ if not (case_id and side and rep_name):
1357
+ raise ValueError("case_id, side and name are required")
1358
+ state_dir = _mcp_state_dir()
1359
+ init_db(state_dir)
1360
+ conn = get_connection(state_dir)
1361
+ try:
1362
+ row = add_case_representative(conn, case_id, side=side, name=rep_name,
1363
+ authority_note=authority_note,
1364
+ power_docs=power_docs)
1365
+ finally:
1366
+ conn.close()
1367
+ if row is None:
1368
+ raise ValueError(f"case not found: {case_id}")
1369
+ from .db import REPRESENTATIVE_HINT
1370
+ return {**row, "hint": REPRESENTATIVE_HINT}
1371
+ if name == "sud_cli_case_rep_list":
1372
+ from .db import REPRESENTATIVE_HINT, get_connection, init_db
1373
+ from .db import list_case_representatives
1374
+ values = dict(args or {})
1375
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1376
+ values.pop("case_id", None)
1377
+ values.pop("id", None)
1378
+ if values:
1379
+ raise ValueError(f"unknown fields: {sorted(values)}")
1380
+ if case_id is None:
1381
+ raise ValueError("case_id is required")
1382
+ state_dir = _mcp_state_dir()
1383
+ init_db(state_dir)
1384
+ conn = get_connection(state_dir)
1385
+ try:
1386
+ rows = list_case_representatives(conn, case_id)
1387
+ finally:
1388
+ conn.close()
1389
+ return {"case_id": case_id, "items": rows, "hint": REPRESENTATIVE_HINT}
1390
+ if name == "sud_cli_calendar_conflicts":
1391
+ from .conflicts import conflicts_payload
1392
+ values = dict(args or {})
1393
+ days = int(values.pop("days", 30))
1394
+ if values:
1395
+ raise ValueError(f"unknown fields: {sorted(values)}")
1396
+ if not 1 <= days <= 3650:
1397
+ raise ValueError("days must be within 1..3650")
1398
+ return conflicts_payload(_mcp_state_dir(), days=days)
1399
+ if name == "sud_cli_report_portfolio":
1400
+ from .reports import portfolio_payload
1401
+ values = dict(args or {})
1402
+ as_of = values.pop("as_of", None)
1403
+ days = int(values.pop("days", 30))
1404
+ if values:
1405
+ raise ValueError(f"unknown fields: {sorted(values)}")
1406
+ if not 1 <= days <= 3650:
1407
+ raise ValueError("days must be within 1..3650")
1408
+ return portfolio_payload(_mcp_state_dir(), as_of=as_of, days=days)
1409
+ if name == "sud_cli_report_case":
1410
+ from .reports import case_payload
1411
+ values = dict(args or {})
1412
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1413
+ values.pop("case_id", None)
1414
+ values.pop("id", None)
1415
+ if values:
1416
+ raise ValueError(f"unknown fields: {sorted(values)}")
1417
+ if case_id is None:
1418
+ raise ValueError("case_id is required")
1419
+ payload = case_payload(_mcp_state_dir(), case_id)
1420
+ if payload is None:
1421
+ raise ValueError(f"case not found: {case_id}")
1422
+ return payload
1423
+ if name == "sud_cli_metrics":
1424
+ from .metrics import metrics_payload
1425
+ values = dict(args or {})
1426
+ near_days = int(values.pop("near_days", 30))
1427
+ if values:
1428
+ raise ValueError(f"unknown fields: {sorted(values)}")
1429
+ if not 1 <= near_days <= 3650:
1430
+ raise ValueError("near_days must be within 1..3650")
1431
+ return metrics_payload(_mcp_state_dir(), near_days=near_days)
1432
+ if name == "sud_cli_backup_create":
1433
+ from pathlib import Path as _Path
1434
+
1435
+ from .backup import create_backup
1436
+ values = dict(args or {})
1437
+ out = values.pop("out", None)
1438
+ if values:
1439
+ raise ValueError(f"unknown fields: {sorted(values)}")
1440
+ payload = create_backup(_mcp_state_dir(), out=_Path(out) if out else None)
1441
+ payload["disclaimer"] = (
1442
+ "Снимок содержит ПДн реестра: файл и sidecar записаны правами 0600."
1443
+ )
1444
+ return payload
1445
+ if name == "sud_cli_backup_verify":
1446
+ from .backup import verify_backup
1447
+ values = dict(args or {})
1448
+ file = values.pop("file", None)
1449
+ if values:
1450
+ raise ValueError(f"unknown fields: {sorted(values)}")
1451
+ if not file:
1452
+ raise ValueError("file is required")
1453
+ payload = verify_backup(file)
1454
+ payload["ok"] = bool(payload["integrity_ok"] and payload["quick_ok"])
1455
+ return payload
1456
+ if name == "sud_cli_backup_restore":
1457
+ from .backup import restore_backup
1458
+ values = dict(args or {})
1459
+ file = values.pop("file", None)
1460
+ if values:
1461
+ raise ValueError(f"unknown fields: {sorted(values)}")
1462
+ if not file:
1463
+ raise ValueError("file is required")
1464
+ # confirm уже снят гейтом _dispatch_gated: сюда доходит только
1465
+ # подтверждённый вызов, поэтому confirmed=True обоснован.
1466
+ return restore_backup(_mcp_state_dir(), file, confirmed=True)
1467
+ if name == "sud_cli_case_export":
1468
+ from .pdn import export_case, render_export_md
1469
+ values = dict(args or {})
1470
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1471
+ fmt = values.pop("format", "json")
1472
+ values.pop("case_id", None)
1473
+ values.pop("id", None)
1474
+ if values:
1475
+ raise ValueError(f"unknown fields: {sorted(values)}")
1476
+ if case_id is None:
1477
+ raise ValueError("case_id is required")
1478
+ payload = export_case(_mcp_state_dir(), case_id, fmt=fmt)
1479
+ if payload is None:
1480
+ raise ValueError(f"case not found: {case_id}")
1481
+ if fmt == "md":
1482
+ payload["markdown"] = render_export_md(payload)
1483
+ return payload
1484
+ if name == "sud_cli_case_purge":
1485
+ from .pdn import purge_apply
1486
+ values = dict(args or {})
1487
+ case_id = _pick(values, ("case_id", "id"), "case_id/id")
1488
+ remove_files = bool(values.pop("remove_files", True))
1489
+ values.pop("case_id", None)
1490
+ values.pop("id", None)
1491
+ if values:
1492
+ raise ValueError(f"unknown fields: {sorted(values)}")
1493
+ if case_id is None:
1494
+ raise ValueError("case_id is required")
1495
+ return purge_apply(_mcp_state_dir(), case_id, confirmed=True,
1496
+ actor="mcp", remove_files=remove_files)
1497
+
1498
+
1499
+ def _tool_schema(name: str) -> dict | None:
1500
+ for t in TOOLS:
1501
+ if t["name"] == name:
1502
+ return t["schema"]
1503
+ return None
1504
+
1505
+
1506
+ def _dispatch_gated(name, args) -> Any:
1507
+ """W6a §1: права + идемпотентность поверх диспатча write-инструментов.
1508
+
1509
+ needs_confirmation — это ДАННЫЕ (status), не ошибка транспорта: запись не
1510
+ произведена, клиент получает явный сигнал повторить с confirm: true.
1511
+ Идемпотентный повтор внутри окна (10 мин) возвращает ПЕРВЫЙ результат с
1512
+ флагом idempotent_replay=true в теле — молча не дублирует и не ругается.
1513
+
1514
+ Fail-closed на неизвестном поле сохранён и до подтверждения: контракт
1515
+ additionalProperties=false и пин v2.4.0 (case_set отклоняет foo) не
1516
+ смещаются гейтом confirm — иначе write-тул становился терпимее read.
1517
+ """
1518
+ if tool_permission(name) != "write":
1519
+ return _call(name, args)
1520
+ values = dict(args or {})
1521
+ confirm = values.pop("confirm", None)
1522
+ idem_key = values.pop("idempotency_key", None)
1523
+ schema = _tool_schema(name) or {}
1524
+ allowed = set((schema.get("properties") or {}).keys())
1525
+ unknown = set(values) - allowed
1526
+ if unknown:
1527
+ raise ValueError(f"unknown fields: {sorted(unknown)}")
1528
+ if confirm is not True:
1529
+ return {
1530
+ "status": "needs_confirmation",
1531
+ "permission": "write",
1532
+ "tool": name,
1533
+ "detail": "write-инструмент требует явного confirm: true — запись не произведена",
1534
+ }
1535
+ from . import idempotency
1536
+ state_dir = _mcp_state_dir()
1537
+ if idem_key is not None and str(idem_key):
1538
+ hit = idempotency.lookup(state_dir, name, str(idem_key))
1539
+ if hit is not None:
1540
+ replay = dict(hit)
1541
+ replay["idempotent_replay"] = True
1542
+ return replay
1543
+ value = _call(name, values)
1544
+ if idem_key is not None and str(idem_key) and isinstance(value, dict):
1545
+ idempotency.record(state_dir, name, str(idem_key), value)
1546
+ return value
1547
+
1548
+
1549
+ def handle_request(request):
1550
+ method = request.get("method")
1551
+ request_id = request.get("id")
1552
+ if method == "initialize":
1553
+ result = {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
1554
+ "serverInfo": {"name": "sud-cli", "version": __version__}}
1555
+ elif method == "notifications/initialized":
1556
+ return None
1557
+ elif method == "tools/list":
1558
+ result = tool_list()
1559
+ elif method == "tools/call":
1560
+ try:
1561
+ params = request.get("params", {})
1562
+ # W6a §1: права (needs_confirmation) и идемпотентность write-тулов
1563
+ # применяются ДО движка — диспатч ниже знает только предметную логику.
1564
+ value = _dispatch_gated(params["name"], params.get("arguments", {}))
1565
+ if hasattr(value, "to_dict"): # v2.3.2 (sa-1 F-05): ApiOutcome -> JSON, не repr
1566
+ value = value.to_dict()
1567
+ result = {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, default=str)}], "isError": False}
1568
+ except (KeyError, TypeError, ValueError, OSError) as exc:
1569
+ result = {"content": [{"type": "text", "text": str(exc)}], "isError": True}
1570
+ except Exception as exc: # v2.3.2 (sa-1 F-06): доменные ошибки -> isError, stdio не умирает
1571
+ result = {"content": [{"type": "text", "text": f"unexpected: {type(exc).__name__}: {exc}"}], "isError": True}
1572
+ else:
1573
+ return {"jsonrpc": "2.0", "id": request_id,
1574
+ "error": {"code": -32601, "message": "Method not found"}}
1575
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
1576
+
1577
+
1578
+ def serve_stdio(input_stream=None, output_stream=None):
1579
+ input_stream = input_stream or sys.stdin
1580
+ output_stream = output_stream or sys.stdout
1581
+ for line in input_stream:
1582
+ if not line.strip():
1583
+ continue
1584
+ # v2.3.3 (review R1): не-JSON строка убивала serve_stdio целиком.
1585
+ try:
1586
+ frame = json.loads(line)
1587
+ except json.JSONDecodeError as exc:
1588
+ response = {"jsonrpc": "2.0", "id": None,
1589
+ "error": {"code": -32700, "message": f"Parse error: {exc}"}}
1590
+ else:
1591
+ response = handle_request(frame)
1592
+ if response is not None:
1593
+ output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
1594
+ output_stream.flush()