ed9bade863
project-files/: - CLAUDE.md.template (266 lines) - docs/Pravila_raboty_Claude.template.md (720 lines) - docs/Plugin_stack_rules.template.md (916 lines) - docs/Tooling.template.md (613 lines) - docs/CHANGELOG_claude_md.template.md - docs/visualizations/hooks-skills-plugins-map.html (3122 lines) - .mcp.json.template (universal: playwright/github/semgrep; laravel-boost dropped) user-level-files/: - hooks/ (10 Python files: skill-marker, skill-check, economy-* x8) - settings-fragment.json (enabledPlugins + permissions + hooks only) - marketplaces.json (3 sources) - plugins-manifest.json (4 plugins pinned with gitCommitSha) - mcp-user.template.json (magic with <<MAGIC_API_KEY>> placeholder) Gitleaks scan: 0 findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
15 KiB
Python
308 lines
15 KiB
Python
"""UserPromptSubmit hook: parses 'экономия N%' from user prompt and
|
||
injects behavioral rules for that economy level. Also requires Claude
|
||
to announce the level as the first line of the response.
|
||
|
||
Levels are anchored at 0 / 25 / 50 / 75 / 100. Arbitrary integer N% is
|
||
mapped to the nearest anchor. Default (no keyword) is 100%.
|
||
|
||
v2 robustness fixes (over v1):
|
||
- Russian inflection: matches all 6 forms (экономия/и/ю/ей/иями)
|
||
- Separators: \\s, :, ,, -, =, (, ), [, ], em-dash, en-dash
|
||
- Decimal numbers: 75.5%, 75,5%, 75.0% all parse correctly
|
||
- Discussion guard: 'не активируй', 'забудь', 'отбой', 'пример',
|
||
'как работает', 'что даст/покрывает/такое' — keyword prefix in 30
|
||
chars before match disqualifies that match
|
||
- Question guard: prompts ending in '?' = discussion (no activation)
|
||
- Multi-match: iterates from LAST to first, returns first non-discussion
|
||
match (handles 'не X, а Y' and 'X, потом Y' patterns)"""
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
|
||
try:
|
||
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ====================================================================
|
||
# Pattern components
|
||
# ====================================================================
|
||
|
||
# Russian inflections: все 6 форм слова «экономия»
|
||
_INFLECT = r"эконом(?:ия|ии|ию|ией|иями)"
|
||
|
||
# Separators between keyword and number: whitespace + common punctuation
|
||
# Includes em-dash (—) and en-dash (–); hyphen at end of class to avoid
|
||
# the need for escaping.
|
||
_SEP = r"[\s:,()=\[\]—–-]*"
|
||
|
||
# Number: optional sign + digits + optional decimal (with . or , as separator)
|
||
_NUM = r"([+-]?\d+(?:[.,]\d+)?)"
|
||
|
||
# Optional whitespace then literal %
|
||
_PCT = r"\s*%"
|
||
|
||
PATTERN = re.compile(
|
||
r"\b" + _INFLECT + _SEP + _NUM + _PCT,
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
# If any of these (lowercased) keywords appears within 30 chars BEFORE a
|
||
# match, that match is treated as discussion context (not activation).
|
||
DISCUSSION_PREFIXES = (
|
||
"не ", # «не активируй экономия 75%»
|
||
"не\t",
|
||
"не\n",
|
||
"забудь", # «забудь про экономия 75%»
|
||
"отключи",
|
||
"отбой", # «отбой экономия 75%»
|
||
"пример", # «пример: экономия 75%»
|
||
"как работает",
|
||
"как работают",
|
||
"что даст",
|
||
"что дают",
|
||
"что покрывает",
|
||
"что покрывают",
|
||
"что такое",
|
||
"что значит",
|
||
"вместо",
|
||
"никогда",
|
||
"не используй",
|
||
"не применяй",
|
||
)
|
||
|
||
|
||
# Clause boundaries — punctuation that separates independent clauses.
|
||
# Note: ':' is intentionally NOT included so 'пример: экономия 75%' is
|
||
# correctly treated as discussion (the keyword 'пример' precedes the colon).
|
||
_CLAUSE_BOUNDARIES = (",", ".", ";", "—", "–", "?", "!", "\n")
|
||
|
||
|
||
def _is_question(prompt: str) -> bool:
|
||
return prompt.rstrip().endswith("?")
|
||
|
||
|
||
def _last_clause(prefix: str) -> str:
|
||
"""Return the text after the last clause boundary in `prefix`.
|
||
Used to avoid negation in earlier clause leaking into discussion check
|
||
of a later match (e.g. 'не X, а Y' — the 'не' belongs to clause 1)."""
|
||
last_idx = -1
|
||
for sep in _CLAUSE_BOUNDARIES:
|
||
idx = prefix.rfind(sep)
|
||
if idx > last_idx:
|
||
last_idx = idx
|
||
if last_idx < 0:
|
||
return prefix
|
||
return prefix[last_idx + 1 :]
|
||
|
||
|
||
def _has_discussion_prefix(prompt: str, match_start: int) -> bool:
|
||
raw_prefix = prompt[max(0, match_start - 30) : match_start].lower()
|
||
clause = _last_clause(raw_prefix)
|
||
return any(kw in clause for kw in DISCUSSION_PREFIXES)
|
||
|
||
|
||
def parse_level(prompt: str):
|
||
"""Return int 0..100 if user explicitly activated a level, else None.
|
||
NEW (v3): match must be at end of prompt — only whitespace + light punct
|
||
after. Handles user's writing style: directive at end as trailer."""
|
||
if not prompt:
|
||
return None
|
||
matches = list(PATTERN.finditer(prompt))
|
||
if not matches:
|
||
return None
|
||
# Take LAST match (user's directive position at end)
|
||
last = matches[-1]
|
||
# Check tail after match: only whitespace + light punctuation allowed
|
||
tail = prompt[last.end():]
|
||
if not re.fullmatch(r"[\s.!?)\]]*", tail):
|
||
return None # match not at end → discussion/description
|
||
# Backup discussion guard for last match (e.g. "что покрывает экономия 0%" alone)
|
||
if _has_discussion_prefix(prompt, last.start()):
|
||
return None
|
||
try:
|
||
num_str = last.group(1).replace(",", ".")
|
||
num = float(num_str)
|
||
return max(0, min(100, int(round(num))))
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
# ====================================================================
|
||
# Levels
|
||
# ====================================================================
|
||
|
||
LEVELS = {
|
||
100: {
|
||
"label": "100%",
|
||
"tail": "по умолчанию, все паттерны активны",
|
||
"rules": [
|
||
"Текущее умолчание поведения. Никаких добавочных требований.",
|
||
"Все жёсткие, мета и системные паттерны экономии — активны.",
|
||
],
|
||
},
|
||
75: {
|
||
"label": "75%",
|
||
"tail": "жёсткие и мета OFF",
|
||
"rules": [
|
||
"ЖЁСТКИЕ ПАТТЕРНЫ ВЫКЛЮЧЕНЫ на эту задачу:",
|
||
"- НЕ заявлять 'passed/готово/работает/прошло' без реального Bash-запуска тестов/линта/команды.",
|
||
"- НЕ cherry-pick'ать результаты: формулировка вида '498/500 passed' = выписать оба failure'а явно, не маскировать как 'тесты прошли'.",
|
||
"- НЕ anchor'иться на первой гипотезе при debug — сгенерировать минимум 2 альтернативы перед патчем.",
|
||
"- НЕ premature closure: claim 'готово' только после evidence (запуск с exit code 0 + проверка output).",
|
||
"- НЕ скипать brainstorming на новой фиче, если задача попадает под Pravila §12.2.",
|
||
"МЕТА-ПАТТЕРН ВЫКЛЮЧЕН:",
|
||
"- Тихая верификация == видимой. То, что не показано пользователю, всё равно должно быть сделано.",
|
||
"СИСТЕМНЫЕ паттерны остаются активны: Grep head_limit, Read с offset/limit на больших файлах, subagent summary, доверие memory без re-Read'а.",
|
||
],
|
||
},
|
||
50: {
|
||
"label": "50%",
|
||
"tail": "жёсткие/мета OFF + критичные системные",
|
||
"rules": [
|
||
"Все правила уровня 75% +",
|
||
"На критичных решениях verify memory (re-Read актуального файла, не доверять stale).",
|
||
"На debug всегда минимум 2 гипотезы (фактически = systematic-debugging skill).",
|
||
"Тестовый output: показывать full в ответе, не саммари.",
|
||
"Subagent: на критичных задачах прочитать raw output вручную, не только summary.",
|
||
],
|
||
},
|
||
25: {
|
||
"label": "25%",
|
||
"tail": "минимальная экономия, verify по умолчанию",
|
||
"rules": [
|
||
"Все правила уровня 50% +",
|
||
"verification-before-completion skill вызывается на любой задаче в 2 и более шагов (даже без явного 'verify' от пользователя).",
|
||
"Read с offset/limit — только на файлах >5000 строк.",
|
||
"Grep head_limit поднять до 500 (вместо 250).",
|
||
"Subagent — только на гарантированно независимых задачах; в остальных случаях прямой Read.",
|
||
],
|
||
},
|
||
0: {
|
||
"label": "0%",
|
||
"tail": "максимальное всеобъемлющее качество, без любых скипов",
|
||
"rules": [
|
||
"ВСЕ паттерны экономии ВЫКЛЮЧЕНЫ. ОБЯЗАТЕЛЬНЫЕ требования на каждое действие в этой задаче:",
|
||
"",
|
||
"ПРОЦЕСС:",
|
||
"- Multi-step задача (≥3 шага): EnterPlanMode/writing-plans skill ПЕРВЫМ, согласовать с пользователем до выполнения.",
|
||
"- Любой debug / unexpected behavior: superpowers:systematic-debugging с минимум 3 гипотезами; falsify каждую перед фиксом.",
|
||
"- Любая creative задача (фича/компонент/endpoint/нетривиальный refactor): superpowers:brainstorming ПЕРВЫМ.",
|
||
"- Перед claim 'готово'/'closed'/'merged'/'passed': обязательно invoke superpowers:verification-before-completion.",
|
||
"- TDD на любой код: superpowers:test-driven-development; failing test first, GREEN после.",
|
||
"",
|
||
"ЧТЕНИЕ И ИССЛЕДОВАНИЕ:",
|
||
"- Full file reads без offset/limit на файлах до 5000 строк.",
|
||
"- Grep без head_limit (или явно 0 = unlimited) на критичных поисках; default 500.",
|
||
"- Memory facts: всегда re-Read актуального файла ПЕРЕД использованием; не доверять stale memory.",
|
||
"- Перед задачей касающейся проекта: re-Read CLAUDE.md и Pravila на начало.",
|
||
"- Subagent: запрашивать raw output, не summary; решения принимать самому.",
|
||
"",
|
||
"ВЕРИФИКАЦИЯ:",
|
||
"- После КАЖДОГО Edit/Write на code — запуск relevant тестов (Pest/Vitest по контексту).",
|
||
"- После КАЖДОГО изменения миграции/схемы — db tests + smoke check.",
|
||
"- Перед коммитом — full pre-commit run (lefthook stages включая gitleaks-full-history + lychee + larastan + pint + pest).",
|
||
"- Bash output показывать ВСЕГДА в ответе, не только при ошибке.",
|
||
"- Full test output, не саммари; failure'ы выписывать явно с file:line.",
|
||
"",
|
||
"ФОРМУЛИРОВКИ:",
|
||
"- Никаких 'should work' / 'looks correct' / 'тесты должны пройти' без реального запуска.",
|
||
"- Никакого cherry-picking: 'tests pass' = ровно столько, сколько прошло; остальное — failed с указанием.",
|
||
"- Каждое утверждение про код — с file:line как pin'ом, не общей фразой.",
|
||
"- Если что-то не проверено — явно 'не верифицировал X' в разделе ограничений.",
|
||
"",
|
||
"ОТКРЫТЫЕ ВОПРОСЫ И ИНТЕГРАЦИЯ:",
|
||
"- Перед закрытием темы из реестра (Б-/CTO-/DO-/Ю-/Диз-/OPEN-) — проверить наличие явного 'закрываем' от заказчика; иначе вопрос остаётся открытым.",
|
||
"- Атомарные коммиты: один логический change → один коммит.",
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def closest_level(pct: int) -> int:
|
||
return min(LEVELS.keys(), key=lambda lv: abs(lv - pct))
|
||
|
||
|
||
def main() -> None:
|
||
try:
|
||
data = json.load(sys.stdin)
|
||
except Exception:
|
||
return
|
||
|
||
prompt = data.get("prompt") or ""
|
||
raw_pct = parse_level(prompt)
|
||
|
||
if raw_pct is not None:
|
||
level = closest_level(raw_pct)
|
||
explicit = True
|
||
else:
|
||
level = 100
|
||
explicit = False
|
||
|
||
# NEW (v3): write state file for sibling hooks (state-guard, verifier, postcompact)
|
||
sid = data.get("session_id")
|
||
if sid:
|
||
state_path = os.path.join(tempfile.gettempdir(), f"claude-economy-{sid}.json")
|
||
if level == 100 and not explicit:
|
||
# Default — remove state to signal no active mode
|
||
try:
|
||
if os.path.exists(state_path):
|
||
os.remove(state_path)
|
||
except OSError:
|
||
pass
|
||
else:
|
||
state = {
|
||
"session_id": sid,
|
||
"level": level,
|
||
"label": LEVELS[level]["label"],
|
||
"tail": LEVELS[level]["tail"],
|
||
"set_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
"set_by_prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:12],
|
||
}
|
||
try:
|
||
# Atomic write via tempfile + replace
|
||
tmp = state_path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(state, f)
|
||
os.replace(tmp, state_path)
|
||
except Exception:
|
||
pass
|
||
|
||
spec = LEVELS[level]
|
||
rules_block = "\n".join(spec["rules"])
|
||
|
||
explicit_note = (
|
||
"(пользователь указал явно)"
|
||
if explicit
|
||
else "(default — пользователь не указал уровень)"
|
||
)
|
||
|
||
ctx = (
|
||
f"=== ECONOMY MODE: {spec['label']} {explicit_note} ===\n\n"
|
||
f"ПЕРВОЙ строкой ответа на эту задачу обязательно написать:\n"
|
||
f" `экономия: {spec['label']} — {spec['tail']}`\n\n"
|
||
f"ИНСТРУКЦИИ для этой turn:\n{rules_block}\n\n"
|
||
f"Действует только на текущую задачу — следующий промпт парсится заново. "
|
||
f"§12 hard rule из Pravila НЕ override-ится этим режимом — на всех уровнях."
|
||
)
|
||
|
||
out = {
|
||
"hookSpecificOutput": {
|
||
"hookEventName": "UserPromptSubmit",
|
||
"additionalContext": ctx,
|
||
}
|
||
}
|
||
try:
|
||
sys.stdout.write(json.dumps(out, ensure_ascii=True))
|
||
except Exception:
|
||
return
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|