/
aizavod
/
gitverse-hackaton-case-3
Обзор
Документация
Войти
/
aizavod
/
gitverse-hackaton-case-3
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
scripts/oc.py
615 строк
31 KB
Artyom Franchuk
v4
29 июл 2026, 08:02
29 июл 2026, 08:02
50fd8c2
Код
Авторство
О чём код?
#!/usr/bin/env python3 """oc.py — единый CLI open-comments. Только стандартная библиотека Python 3.10+, без внешних зависимостей (json/re/pathlib/hashlib/argparse) — один файл на секьюрити-ревью. Рабочие данные проекта живут в каталоге ./open-comments/ (переопределяется env OC_DIR или флагом --dir) и создаются командой init — по аналогии с `openspec init`: open-comments/ config/autonomy.json матрица «автор/роль/тип → уровень автономности» config/reviewers.json справочник ответственных, эскалация, валидатор memory/decisions/ эпизодическая память решений (питает rag search) memory/changelog.md глобальная история change/ активные change (proposal.md, tasks.md, state.json, …) archive/ завершённые change Подкоманды: install [--target .gigacode] — ПРОЕКТНАЯ установка (как openspec init): скиллы, команды и контекст — в каталог проекта, не в home init [--dir D] [--force] — развернуть только рабочий каталог данных diff apply --plan plan.json | merge | lint … — AI-ready цветной diff (storage format); apply = ВСЕ правки одним вызовом + авто-lint state init|get|set|task|progress … — машина состояний change/тасков autonomy resolve --author … [--role] [--type]— уровень: auto-apply|propose|ask plan [--dir D] — следующий шаг по каждому change (питает /oc:continue) rag search "текст" [-k N] — поиск похожих решённых комментариев Запусти `oc.py <подкоманда> -h` для деталей. """ import argparse import hashlib import html import json import math import os import pathlib import re import sys from collections import Counter from datetime import datetime, timezone # ============================================================ рабочий каталог def data_dir(cli_dir: str | None = None) -> pathlib.Path: return pathlib.Path(cli_dir or os.environ.get("OC_DIR", "open-comments")) # ============================================================ init DEFAULT_AUTONOMY = { "_comment": [ "Уровни: auto-apply | propose | ask. Резолвер: by_author главнее всего;", "иначе берётся СТРОЖАЙШИЙ из (by_role, by_type), затем default.", "В by_type перечисляй только типы СТРОЖЕ default — иначе заблокируешь", "auto-apply доверенных ролей (напр. доработка от техписателя).", ], "default": "propose", "by_role": { "compliance": "ask", "legal": "ask", "tech-writer": "auto-apply", "product-owner": "propose", "architect": "propose", }, "by_type": { "блокер": "propose", "риск": "propose", "противоречие": "ask", "вопрос": "ask", "off-topic": "ask", }, "by_author": {}, } DEFAULT_REVIEWERS = { "_comment": ["Заполни составом команды: имена как в Confluence, роли — как в autonomy.json"], "reviewers": [ {"name": "<Фамилия>", "role": "product-owner", "scope": "<зона ответственности>"}, ], "escalation": {"противоречие": ["<Фамилия>"], "default": ["<Фамилия>"]}, "validator": "<Фамилия валидатора>", } def _install_assets(target: str) -> str: """Скиллы → <target>/skills/, команды → <target>/commands/, инварианты → GIGACODE.md проекта (идемпотентно между маркерами). Возвращает саммари.""" ext = pathlib.Path(__file__).resolve().parent.parent if not (ext / "skills").exists(): # запуск из самокопии в open-comments/scripts return "" n = 0 for sub in ("skills", "commands"): for f in sorted((ext / sub).rglob("*")): if f.is_dir() or "__pycache__" in f.parts: continue out = pathlib.Path(target) / sub / f.relative_to(ext / sub) out.parent.mkdir(parents=True, exist_ok=True) text = f.read_text(encoding="utf-8") if target != ".gigacode": text = text.replace(".gigacode/", f"{target}/") out.write_text(text, encoding="utf-8") n += 1 block = (ext / "GIGACODE.md").read_text(encoding="utf-8") proj = pathlib.Path("GIGACODE.md") cur = proj.read_text(encoding="utf-8") if proj.exists() else "" if GIGACODE_BEGIN in cur: head, rest = cur.split(GIGACODE_BEGIN, 1) _, tail = rest.split(GIGACODE_END, 1) cur = head + GIGACODE_BEGIN + "\n" + block + "\n" + GIGACODE_END + tail else: cur = (cur + "\n\n" if cur else "") + GIGACODE_BEGIN + "\n" + block + "\n" + GIGACODE_END + "\n" proj.write_text(cur, encoding="utf-8") return f"{n} файлов → {target}/skills/ и {target}/commands/, инварианты → GIGACODE.md" def cmd_init(a) -> None: root = data_dir(a.dir) if root.exists() and any(root.iterdir()) and not a.force: sys.exit(f"ERROR: {root} уже существует и не пуст (используй --force для дозаписи недостающего)") made = [] for sub in ("config", "memory/decisions", "change", "archive"): (root / sub).mkdir(parents=True, exist_ok=True) for sub in ("change", "archive", "memory/decisions"): gk = root / sub / ".gitkeep" if not gk.exists(): gk.write_text("") for name, payload in (("autonomy.json", DEFAULT_AUTONOMY), ("reviewers.json", DEFAULT_REVIEWERS)): f = root / "config" / name if not f.exists(): f.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") made.append(str(f)) ch = root / "memory" / "changelog.md" if not ch.exists(): ch.write_text("# Changelog решений\n<!-- пополняется /oc:archive -->\n", encoding="utf-8") made.append(str(ch)) # самокопирование: проект получает собственную, пинуемую в git копию скриптов src_dir = pathlib.Path(__file__).resolve().parent (root / "scripts").mkdir(exist_ok=True) for fname in ("oc.py", "provider.py"): src = src_dir / fname dst = root / "scripts" / fname if src.exists() and src.resolve() != dst.resolve(): dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") made.append(str(dst)) print(f"open-comments инициализирован: {root}/") for f in made: print(f" создано: {f}") if getattr(a, "assets", True): summary = _install_assets(getattr(a, "target", None) or ".gigacode") if summary: print(f" проектные скиллы/команды: {summary}") print("Следующие шаги: 1) заполни config/reviewers.json составом команды;" " 2) сверь config/autonomy.json; 3) перезапусти CLI; 4) /oc:new <ссылка>") # ============================================================ diff (storage format) RED, BLUE, GREEN = "rgb(255,0,0)", "rgb(0,0,255)", "rgb(0,128,0)" LABELS = {"del": "удаляется", "change": "изменяется", "add": "добавляется"} COLORS = {"del": RED, "change": BLUE, "add": GREEN} MARKER_RE = re.compile(r"<ac:inline-comment-marker\b[^>]*>.*?</ac:inline-comment-marker>", re.S) # ВАЖНО: Confluence при сохранении может нормализовать стиль (rgb→hex, лишние атрибуты), # поэтому разбор ключуется на ТЕКСТОВЫХ метках [удаляется|изменяется|добавляется] [[id]], # а не на точном CSS — merge/lint устойчивы к любому виду атрибутов span. _SPAN_RE = re.compile( r"<span\b[^>]*>" r"\[(?P<label>удаляется|изменяется|добавляется)\] \[\[(?P<id>[^\]]+)\]\] " r"(?P<inner>.*?)</span>", re.S) _RED_SPAN_RE = re.compile( r" ?<span\b[^>]*>\[удаляется\] \[\[[^\]]+\]\] .*?</span> ?", re.S) def _span(kind: str, frag_id: str, inner: str) -> str: return f'<span style="color: {COLORS[kind]};">[{LABELS[kind]}] [[{frag_id}]] {inner}</span>' def _find_once(body: str, needle: str) -> int: first = body.find(needle) if first == -1: sys.exit(f"ERROR: фрагмент не найден в теле страницы: {needle[:80]!r}") if body.find(needle, first + 1) != -1: sys.exit("ERROR: фрагмент встречается несколько раз — уточните контекст " f"(дубли ломают якоря inline-комментариев): {needle[:80]!r}") return first def mark_del(body, fragment, frag_id): pos = _find_once(body, fragment) return body[:pos] + _span("del", frag_id, fragment) + body[pos + len(fragment):] def mark_add(body, after, new_text, frag_id): pos = _find_once(body, after) + len(after) return body[:pos] + " " + _span("add", frag_id, html.escape(new_text, quote=False)) + body[pos:] def mark_change(body, fragment, new_text, frag_id): """Изменение = пара: старый текст красным, новая формулировка синим рядом.""" pos = _find_once(body, fragment) marked = _span("del", frag_id, fragment) + " " + _span("change", frag_id, html.escape(new_text, quote=False)) return body[:pos] + marked + body[pos + len(fragment):] def merge(body: str) -> str: """Красное удаляется (вместе с якорями — согласование получено), синее/зелёное разворачивается в чистый текст. Чистка пробелов ТОЧЕЧНАЯ (один смежный пробел вокруг красного span) — без глобальной нормализации, чтобы не портить CDATA/код-макросы.""" out = _RED_SPAN_RE.sub(" ", body) out = _SPAN_RE.sub(lambda m: m.group("inner"), out) return out.replace("<p> ", "<p>").replace(" </p>", "</p>") def lint(body: str) -> int: errors = 0 ids: dict[str, list[str]] = {} for m in _SPAN_RE.finditer(body): ids.setdefault(m.group("id"), []).append(m.group("label")) if m.group("label") in ("изменяется", "добавляется") and MARKER_RE.search(m.group("inner")): print(f"LINT ERROR: якорь inline-комментария внутри нового текста [[{m.group('id')}]] — " "новый контент нельзя вставлять внутрь маркера", file=sys.stderr) errors += 1 for fid, labels in ids.items(): if "изменяется" in labels and "удаляется" not in labels: print(f"LINT WARN: [[{fid}]] помечен как изменение без парной красной метки", file=sys.stderr) print(f"lint: {len(ids)} размеченных фрагментов, {len(MARKER_RE.findall(body))} inline-якорей, " f"{errors} ошибок", file=sys.stderr) return errors def cmd_diff(a) -> None: body = open(a.file, encoding="utf-8").read() if a.action == "merge": print(merge(body), end=""); return if a.action == "lint": sys.exit(1 if lint(body) else 0) if a.action == "apply": # Пакетный режим (рекомендуемый): все правки — одним вызовом из плана-файла. # plan.json: [{"op":"change","find":"<старый текст>","new":"<новый>","id":"frag-1"}, # {"op":"add","after":"<текст-якорь>","new":"<текст>"}, # {"op":"del","find":"<текст>"}] if not a.plan: sys.exit("ERROR: diff apply требует --plan plan.json") try: plan = json.loads(pathlib.Path(a.plan).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: sys.exit(f"ERROR: не смог прочитать план {a.plan}: {e}") if not isinstance(plan, list) or not plan: sys.exit("ERROR: план должен быть непустым JSON-массивом операций") for i, op in enumerate(plan, 1): kind = op.get("op") fid = op.get("id") or "frag-" + hashlib.md5( json.dumps(op, ensure_ascii=False, sort_keys=True).encode()).hexdigest()[:6] try: if kind == "del": body = mark_del(body, op["find"], fid) elif kind == "change": body = mark_change(body, op["find"], op["new"], fid) elif kind == "add": body = mark_add(body, op["after"], op["new"], fid) else: sys.exit(f"ERROR: операция №{i}: op должен быть del|change|add, получен {kind!r}") except KeyError as e: sys.exit(f"ERROR: операция №{i} ({kind}): нет обязательного поля {e}") if lint(body): sys.exit("ERROR: план применён, но lint нашёл ошибки — исправь план и повтори") print(body, end=""); return frag_id = a.id or "frag-" + hashlib.md5("|".join([a.action, *a.args]).encode()).hexdigest()[:6] fn = {"mark-del": lambda: mark_del(body, a.args[0], frag_id), "mark-add": lambda: mark_add(body, a.args[0], a.args[1], frag_id), "mark-change": lambda: mark_change(body, a.args[0], a.args[1], frag_id)}[a.action] print(fn(), end="") # ============================================================ state machine CHANGE_FLOW = { "draft": {"proposed"}, "proposed": {"in-review"}, "in-review": {"changes-requested", "approved"}, "changes-requested": {"in-review"}, "approved": {"merged"}, "merged": {"archived"}, "archived": set(), } TASK_FLOW = { "open": {"drafted", "needs-clarification", "wont-fix"}, "drafted": {"replied", "needs-clarification"}, "replied": {"acknowledged", "drafted"}, "acknowledged": {"validator-ok", "drafted"}, "validator-ok": {"merged"}, "needs-clarification": {"open", "drafted", "wont-fix"}, "wont-fix": set(), "merged": set(), } DONE = {"acknowledged", "validator-ok", "merged", "wont-fix"} def _st_path(d): return pathlib.Path(d) / "state.json" def _st_load(d): p = _st_path(d) if not p.exists(): sys.exit(f"ERROR: {p} не найден — сначала `oc.py state init`") return json.loads(p.read_text(encoding="utf-8")) def _st_save(d, st): st["updated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") _st_path(d).write_text(json.dumps(st, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def _transition(flow, cur, target, what): if target == cur: return # идемпотентность if target not in flow.get(cur, set()): sys.exit(f"ERROR: недопустимый переход {what}: {cur} → {target}") def cmd_state(a) -> None: if a.action == "init": p = _st_path(a.change_dir) if p.exists(): print(f"already initialized: {p}"); return p.parent.mkdir(parents=True, exist_ok=True) _st_save(a.change_dir, {"state": "draft", "page_id": a.page_id, "page_url": a.page_url or "", "page_version": None, "tasks": {}}) print(f"initialized: {p} (draft)"); return st = _st_load(a.change_dir) if a.action == "get": print(json.dumps(st["tasks"].get(a.task, {}) if a.task else st, ensure_ascii=False, indent=2)) elif a.action == "set": _transition(CHANGE_FLOW, st["state"], a.state, "change") st["state"] = a.state; _st_save(a.change_dir, st); print(f"change → {a.state}") elif a.action == "task": cur = st["tasks"].get(a.task, {}).get("state", "open") _transition(TASK_FLOW, cur, a.state, f"task {a.task}") st["tasks"].setdefault(a.task, {})["state"] = a.state _st_save(a.change_dir, st); print(f"task {a.task} → {a.state}") elif a.action == "version": if a.page_version is None: print(json.dumps({"page_version": st.get("page_version"), "last_seen_at": st.get("updated_at")}, ensure_ascii=False)) else: st["page_version"] = a.page_version _st_save(a.change_dir, st) print(f"page_version → {a.page_version}") elif a.action == "progress": t = st["tasks"] print(json.dumps({"change_state": st["state"], "tasks_total": len(t), "tasks_done": sum(1 for x in t.values() if x.get("state") in DONE), "tasks_waiting": sum(1 for x in t.values() if x.get("state") in ("replied", "needs-clarification"))}, ensure_ascii=False)) # ============================================================ autonomy LEVELS = {"auto-apply", "propose", "ask"} STRICTNESS = {"auto-apply": 0, "propose": 1, "ask": 2} def resolve_autonomy(author, role, ctype, cfg, reviewers=None): """by_author главнее всего; иначе строжайший из (роль, тип); затем default. Доверенная роль не может авто-применить рискованный тип.""" reviewers = reviewers or {} if not role: for person in reviewers.get("reviewers", []): if person.get("name") == author: role = person.get("role"); break def _lookup(source, key): if key and key in cfg.get(source, {}): level = cfg[source][key] if level not in LEVELS: sys.exit(f"ERROR: неизвестный уровень {level!r} в {source}.{key}") return level return None by_author = _lookup("by_author", author) if by_author: return by_author candidates = [lvl for lvl in (_lookup("by_role", role), _lookup("by_type", ctype)) if lvl] if candidates: return max(candidates, key=STRICTNESS.__getitem__) return cfg.get("default", "propose") def cmd_autonomy(a) -> None: root = data_dir(a.dir) cfg = json.loads((root / "config" / "autonomy.json").read_text(encoding="utf-8")) rev_p = root / "config" / "reviewers.json" reviewers = json.loads(rev_p.read_text(encoding="utf-8")) if rev_p.exists() else {} print(resolve_autonomy(a.author, a.role, a.type, cfg, reviewers)) # ============================================================ rag (лексический TF-IDF) TOKEN_RE = re.compile(r"[а-яёa-z0-9]+", re.I) SUFFIXES = ("иями", "ями", "ами", "ого", "ему", "ыми", "ими", "ует", "ать", "ять", "ой", "ый", "ий", "ая", "яя", "ое", "ее", "ов", "ев", "ам", "ям", "ах", "ях", "ом", "ем", "ть", "а", "я", "о", "е", "ы", "и", "у", "ю", "ь") def _stem(tok: str) -> str: tok = tok.lower() if len(tok) <= 3: return tok for suf in SUFFIXES: if tok.endswith(suf) and len(tok) - len(suf) >= 3: return tok[: -len(suf)] return tok def _tokens(text: str) -> Counter: return Counter(_stem(t) for t in TOKEN_RE.findall(text)) def cmd_rag(a) -> None: directory = data_dir(a.dir) / "memory" / "decisions" docs = {f: _tokens(f.read_text(encoding="utf-8")) for f in sorted(directory.glob("*.md"))} if not docs: print("no matches"); return n, df = len(docs), Counter() for tf in docs.values(): df.update(tf.keys()) q = _tokens(a.query) scored = [] for f, tf in docs.items(): score = sum(q[t] * tf[t] * math.log(1 + n / df[t]) for t in q if t in tf) \ / (1 + math.log(1 + sum(tf.values()))) if score > 0: scored.append((score, f)) scored.sort(key=lambda x: (-x[0], x[1].name)) hits = scored[: a.k] if not hits: print("no matches"); return for score, f in hits: print(f"{score:.3f}\t{f}") # ============================================================ install (проектная установка) GIGACODE_BEGIN = "<!-- open-comments:begin (управляется oc.py install, не редактируй внутри) -->" GIGACODE_END = "<!-- open-comments:end -->" def cmd_install(a) -> None: """Установка на УРОВНЕ ПРОЕКТА (не в домашнюю папку). Два режима: --mode extension (default): полный extension → <target>/extensions/open-comments/. CLI сам подхватывает манифест (включая mcpServers → confluence-mcp-server подключается автоматически), контекст GIGACODE.md и команды extension'а. --mode flat: «плоская» раскладка — скиллы в <target>/skills/, команды в <target>/commands/, инварианты дописываются в GIGACODE.md проекта. Fallback, если сборка CLI не читает workspace-extensions. Оба режима идемпотентны и завершаются init рабочего каталога данных.""" ext = pathlib.Path(__file__).resolve().parent.parent # корень extension/репозитория target = a.target.strip("/") def _copy_tree(src: pathlib.Path, dst: pathlib.Path, rewrite_skills_to: str | None = None): n = 0 for f in sorted(src.rglob("*")): if f.is_dir() or "__pycache__" in f.parts: continue out = dst / f.relative_to(src) out.parent.mkdir(parents=True, exist_ok=True) try: text = f.read_text(encoding="utf-8") if target != ".gigacode": text = text.replace(".gigacode/", f"{target}/") if rewrite_skills_to: text = text.replace(f"{target}/skills/", rewrite_skills_to) out.write_text(text, encoding="utf-8") except UnicodeDecodeError: out.write_bytes(f.read_bytes()) n += 1 return n if a.mode == "extension": dst = pathlib.Path(target) / "extensions" / "open-comments" skills_path = f"{target}/extensions/open-comments/skills/" n = 0 for sub in ("skills", "commands", "scripts", "demo"): if (ext / sub).exists(): n += _copy_tree(ext / sub, dst / sub, rewrite_skills_to=skills_path) for fname in ("GIGACODE.md", "README.md", "gigacode-extension.json", "qwen-extension.json", "gemini-extension.json"): if (ext / fname).exists(): (dst / fname).write_text((ext / fname).read_text(encoding="utf-8"), encoding="utf-8") n += 1 print(f"локальный extension установлен: {n} файлов → {dst}/ " "(манифест+MCP, контекст и команды подхватятся автоматически)") else: print("flat-установка:", _install_assets(target) or "(ассеты не найдены рядом со скриптом)") class _A: dir = a.dir; force = True; assets = (a.mode != "extension"); target = target cmd_init(_A) print("Перезапусти GigaCode CLI в каталоге проекта — /oc:* подхватятся с проектного уровня.") # ============================================================ plan (что дальше) NEXT_BY_STATE = { "draft": "/oc:new — допиши proposal.md и переведи change в proposed", "proposed": "/oc:sync — собрать комментарии со страницы", "approved": "/oc:merge — финализировать согласованный diff", "merged": "/oc:archive — выжимка в память решений и перенос в archive/", "archived": None, } def _next_action(st: dict) -> tuple[str | None, str]: """(следующая команда | None если всё сделано, чего ждём).""" cs, tasks = st["state"], st["tasks"] if cs in NEXT_BY_STATE: return NEXT_BY_STATE[cs], "" if NEXT_BY_STATE[cs] else "завершено" # in-review / changes-requested — смотрим таски states = [t.get("state", "open") for t in tasks.values()] if not tasks: return "/oc:sync — комментарии ещё не собраны", "" if any(x in ("open", "drafted") for x in states): return "/oc:apply — есть неотработанные комментарии", "" if any(x == "needs-clarification" for x in states): return "/oc:apply — проверить, пришли ли уточнения", "ответы людей на уточняющие вопросы" if any(x == "replied" for x in states): return "/oc:apply — проверить «ок»/«не ок» в тредах", "«ок» авторов комментариев" if all(x in DONE for x in states) and any(x == "acknowledged" for x in states): return "/oc:review — тегнуть валидатора", "" if any(x == "validator-ok" for x in states): return "/oc:merge — вердикт валидатора получен", "" return "/oc:view — состояние неоднозначно, посмотри детали", "" def cmd_plan(a) -> None: root = data_dir(a.dir) items = [] for st_file in sorted((root / "change").rglob("state.json")): st = json.loads(st_file.read_text(encoding="utf-8")) nxt, waiting = _next_action(st) tasks = st["tasks"] items.append({ "change": str(st_file.parent.relative_to(root / "change")), "state": st["state"], "tasks_done": sum(1 for t in tasks.values() if t.get("state") in DONE), "tasks_total": len(tasks), "next": nxt, "waiting_for": waiting, }) actionable = [i for i in items if i["next"] and not i["waiting_for"]] print(json.dumps({"changes": items, "actionable": [i["change"] for i in actionable], "all_done": not any(i["next"] for i in items)}, ensure_ascii=False, indent=2)) # ============================================================ argparse def main() -> None: p = argparse.ArgumentParser(prog="oc.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = p.add_subparsers(dest="cmd", required=True) sp = sub.add_parser("init", help="рабочий каталог open-comments/ + скиллы/команды в <target>/ проекта") sp.add_argument("--dir"); sp.add_argument("--force", action="store_true") sp.add_argument("--target", default=".gigacode") sp.set_defaults(fn=cmd_init) sp = sub.add_parser("diff", help="AI-ready цветной diff на storage format") sp.add_argument("action", choices=["apply", "mark-del", "mark-add", "mark-change", "merge", "lint"]) sp.add_argument("file") sp.add_argument("args", nargs="*") sp.add_argument("--id") sp.add_argument("--plan", help="JSON-план правок для действия apply (рекомендуемый режим)") sp.set_defaults(fn=cmd_diff) sp = sub.add_parser("state", help="машина состояний change/тасков (state.json)") sp.add_argument("action", choices=["init", "get", "set", "task", "progress", "version"]) sp.add_argument("change_dir") sp.add_argument("--page-id"); sp.add_argument("--page-url") sp.add_argument("--task"); sp.add_argument("--state") sp.add_argument("--page-version", type=int) sp.set_defaults(fn=cmd_state) sp = sub.add_parser("autonomy", help="уровень автономности для комментария") sp.add_argument("resolve_kw", nargs="?", default="resolve", choices=["resolve"]) sp.add_argument("--author", required=True) sp.add_argument("--role"); sp.add_argument("--type"); sp.add_argument("--dir") sp.set_defaults(fn=cmd_autonomy) sp = sub.add_parser("install", help="проектная установка: локальный extension (default) или flat-раскладка") sp.add_argument("--mode", default="flat", choices=["flat", "extension"], help="extension: <target>/extensions/open-comments/ c манифестом и MCP (default); flat: скиллы/команды прямо в <target>/") sp.add_argument("--target", default=".gigacode", help="конфиг-каталог CLI в проекте; GigaCode (форк Qwen CLI) = .gigacode (default); ванильный Qwen Code = .qwen") sp.add_argument("--dir") sp.set_defaults(fn=cmd_install) sp = sub.add_parser("plan", help="следующий шаг по каждому change (по машине состояний)") sp.add_argument("--dir") sp.set_defaults(fn=cmd_plan) sp = sub.add_parser("rag", help="поиск похожих решённых комментариев") sp.add_argument("search_kw", nargs="?", default="search", choices=["search"]) sp.add_argument("query") sp.add_argument("-k", type=int, default=3) sp.add_argument("--dir") sp.set_defaults(fn=cmd_rag) a = p.parse_args() # валидация комбинаций для state if a.cmd == "state": need = {"init": ["page_id"], "set": ["state"], "task": ["task", "state"]} # state version без --page-version = чтение, с ним = запись for req in need.get(a.action, []): if getattr(a, req) is None: p.error(f"state {a.action}: требуется --{req.replace('_', '-')}") if a.action == "set" and a.state not in CHANGE_FLOW: p.error(f"неизвестное состояние change: {a.state}") if a.action == "task" and a.state not in TASK_FLOW: p.error(f"неизвестное состояние таска: {a.state}") a.fn(a) if __name__ == "__main__": try: main() except BrokenPipeError: sys.exit(0)