#!/usr/bin/env python3 """Append-only registry of live work sessions: open/touch/done/close/pickup; check печатает ДЕЙСТВИЕ при пересечении.""" from __future__ import annotations import argparse import datetime as dt import fcntl import json import re from pathlib import Path import sys as _sys; _sys.path.insert(0, str(Path(__file__).resolve().parent)) from kit import HOME # корень дома из kit.json (HOME_KIT_ROOT) EVENTS = HOME / "state" / "sessions" / "events.jsonl" WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]+") MSK = dt.timezone(dt.timedelta(hours=3)) LIVE_HOURS = 1.5 # окно «живой» сессии (как было до 05.09) ORPHAN_SCAN_HOURS = 24 # сироты ищутся в суточном окне: 90 мин их скрывали (замер 05.09: 9 из 12 молчавших без сдачи — старше 90 мин) # Пороги действия при пересечении — по замеру events.jsonl 05.09.2026 (окно 24 ч, 47 сессий, 116 рабочих пауз до следующего события той же сессии): # p50 1.1 мин, p90 18.5, p95 26.4 → «активна» = молчит меньше 30 мин (p95 с запасом); # пауз 60 мин и дольше, после которых сессия всё же вернулась, — 4 из 116 (3.4%) → молчание дольше 60 мин без сдачи = сирота. # Пересмотр — тем же замером: decisions/2026-09-05-z7-session-pickup.md. PICKUP_MIN = 30 ORPHAN_MIN = 60 def now() -> dt.datetime: return dt.datetime.now(dt.timezone.utc) def parse_ts(value: str) -> dt.datetime: parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) return parsed.replace(tzinfo=dt.timezone.utc) if parsed.tzinfo is None else parsed.astimezone(dt.timezone.utc) def files_arg(value: str | None) -> list[str]: return list(dict.fromkeys(x.strip() for x in (value or "").split(",") if x.strip())) def append(kind: str, label: str, **fields: object) -> dict: rec = {"ts": now().isoformat(timespec="seconds"), "kind": kind, "label": label} rec.update({k: v for k, v in fields.items() if v not in (None, "", [])}) EVENTS.parent.mkdir(parents=True, exist_ok=True) with EVENTS.open("a", encoding="utf-8") as out: fcntl.flock(out, fcntl.LOCK_EX) out.write(json.dumps(rec, ensure_ascii=False) + "\n") out.flush() fcntl.flock(out, fcntl.LOCK_UN) return rec def rows() -> list[dict]: if not EVENTS.exists(): return [] result = [] for line in EVENTS.read_text(encoding="utf-8").splitlines(): try: row = json.loads(line) row["_ts"] = parse_ts(row["ts"]) result.append(row) except (ValueError, TypeError, KeyError, json.JSONDecodeError): continue return result def blank(label: str) -> dict: return {"label": label, "opened": False, "closed": False, "subject": "", "files": [], "project": "", "surface": "", "item": "", "done_ts": None, "done_text": "", "picked_by": "", "last": None} def states() -> dict[str, dict]: """Состояние каждой метки по журналу. open дополняет поля, а не стирает их (повторный open лестницы без предмета).""" state: dict[str, dict] = {} for row in rows(): label = str(row.get("label", "")) if not label: continue item = state.setdefault(label, blank(label)) kind = row.get("kind") if kind == "open": item.update({"opened": True, "closed": False}) for key in ("subject", "project", "files", "surface", "item"): if row.get(key): item[key] = row[key] elif kind == "close": item["closed"] = True elif kind == "pickup": item.update({"closed": True, "picked_by": str(row.get("by", ""))}) elif kind == "done": if row.get("text"): item["subject"] = str(row["text"]).split("→")[0].strip()[:60] or item["subject"] item["done_text"] = str(row["text"])[:120] if row.get("item"): item["item"] = str(row["item"]) item["done_ts"] = row["_ts"] elif kind == "touch": if row.get("subject") and not item["subject"]: item["subject"] = str(row["subject"])[:60] if row.get("item"): item["item"] = str(row["item"]) item["last"] = row["_ts"] return state def alive(state: dict[str, dict], hours: float) -> list[dict]: cutoff = now() - dt.timedelta(hours=hours) return sorted((x for x in state.values() if x["opened"] and not x["closed"] and x["last"] and x["last"] >= cutoff), key=lambda x: x["last"], reverse=True) def live(hours: float = LIVE_HOURS) -> list[dict]: return alive(states(), hours) def age_minutes(item: dict) -> int: return max(0, int((now() - item["last"]).total_seconds() // 60)) def age_text(item: dict) -> str: minutes = age_minutes(item) if minutes < 60: return f"{minutes} мин" return f"{minutes // 60} ч {minutes % 60} мин" # Служебные слова предметов: поверхность, ритуал, метки — не предмет. Без них 05.09 «дубли предметов» = 42 пары из 28 живых # (замер: общий зачин «Проснись…», токены меток и дат давали ≥2 общих слова почти любой паре). STOP = {"сессия", "сессии", "сессию", "сессией", "проснись", "метка", "давай", "той", "же", "для", "при", "что", "как", "это", "маркер", "cowork", "chat", "headless", "mobile", "telegram", "проект", "проекту", "проекте", "задача", "задачи", "задачу", "ход", "ходы", "шаг", "step", "next", "жду", "готово", "продолжай", "дальше"} def words(subject: str) -> set[str]: """Содержательные слова предмета: ≥3 букв, без цифр (метки, даты, пункты сравниваются полями, не словами), без служебных.""" return {x.lower() for x in WORD_RE.findall(subject) if len(x) >= 3 and not any(ch.isdigit() for ch in x) and x.lower() not in STOP} def overlaps(item: dict, subject: str = "", files: list[str] | None = None) -> bool: same_files = bool(set(files or ()) & set(item.get("files", ()))) same_words = len(words(subject) & words(str(item.get("subject", "")))) >= 2 return same_files or same_words def tag(item: dict) -> str: return f"{item['label']} [{item.get('project') or '—'}/{item.get('item') or '—'}]" def relation(me: dict, other: dict) -> tuple[str, str] | None: """(класс, строка) для встречной сессии; None — пересечения нет. ПОДХВАТ — тот же пункт или предмет; ПАРАЛЛЕЛЬНО — другой пункт того же проекта; СИРОТА — молчит дольше ORPHAN_MIN без сдачи.""" same_item = bool(me.get("item")) and me.get("item") == other.get("item") same_project = bool(me.get("project")) and str(me.get("project", "")).lower() == str(other.get("project", "")).lower() same_subject = overlaps(other, me.get("subject", ""), me.get("files")) if not (same_item or same_project or same_subject): return None silent = age_minutes(other) if silent > ORPHAN_MIN and not other.get("done_ts"): if not (other.get("item") or same_item or same_subject): return ("СИРОТА-БЕЗ-ПУНКТА", "") return ("СИРОТА", f"СИРОТА: {tag(other)} молчит {silent} мин без сдачи → подхватить её пункт и отметить: " f"python3 bin/session-reg.py pickup {other['label']} --by {me['label']}") if silent > LIVE_HOURS * 60: return None if same_item or same_subject: if other.get("done_ts"): last = f"последняя сдача {other['done_ts'].astimezone(MSK):%H:%M} мск «{other['done_text'][:90]}»" else: last = f"сдач нет, предмет «{other.get('subject') or '—'}»" pace = f"активна ({silent} мин)" if silent < PICKUP_MIN else f"пауза {silent} мин (дольше {PICKUP_MIN} — возможно встала)" return ("ПОДХВАТ", f"ПОДХВАТ: {tag(other)} {pace}; {last} → прочитать её сдачи (events.jsonl), продолжать с её шага, не дублировать") return ("ПАРАЛЛЕЛЬНО", other.get("item") or "—") def purge(prefix: str) -> int: """Убрать из реестра ПРОБНЫЕ метки (в префиксе обязано быть «probe»): перезапись на месте под flock, инод тот же — параллельный append дождётся замка и допишет в тот же файл.""" if "probe" not in prefix.lower(): raise SystemExit("purge только для пробных меток: в префиксе должно быть «probe»") if not EVENTS.exists(): return 0 with EVENTS.open("r+", encoding="utf-8") as stream: fcntl.flock(stream, fcntl.LOCK_EX) lines = stream.read().splitlines() kept = [] for line in lines: try: label = str(json.loads(line).get("label", "")) except (ValueError, TypeError, json.JSONDecodeError): kept.append(line); continue if not label.startswith(prefix): kept.append(line) stream.seek(0); stream.truncate() stream.write("".join(x + "\n" for x in kept)); stream.flush() fcntl.flock(stream, fcntl.LOCK_UN) return len(lines) - len(kept) def print_list(hours: float) -> None: items = live(hours) print(f"живых сессий: {len(items)}") for item in items: subject = item.get("subject") or "без предмета" names = ",".join(item.get("files", ())) or "—" proj = f" [{item['project']}/{item.get('item') or '—'}]" if item.get("project") else "" print(f"{item['label']}{proj}: {age_text(item)}; предмет: {subject}; файлы: {names}") def print_check(label: str, subject: str = "", files: list[str] | None = None, project: str = "", item: str = "") -> None: state = states() me = dict(state.get(label) or blank(label)) for key, value in (("subject", subject), ("files", files or []), ("project", project), ("item", item)): if value: me[key] = value if me.get("picked_by"): print(f"⚠ эта сессия ({label}) подхвачена {me['picked_by']}: её пункт ведёт другая сессия — согласовать, не дублировать") live_count = len(alive(state, LIVE_HOURS)) lines, parallel_items, orphans_without_item = [], [], 0 for other in alive(state, ORPHAN_SCAN_HOURS): if other["label"] == label: continue rel = relation(me, other) if rel is None: continue kind, text = rel if kind == "ПАРАЛЛЕЛЬНО": parallel_items.append(text) elif kind == "СИРОТА-БЕЗ-ПУНКТА": orphans_without_item += 1 else: lines.append(text) if parallel_items: listed = ", ".join(sorted(set(parallel_items))) lines.append(f"ПАРАЛЛЕЛЬНО: {len(parallel_items)} сессий проекта «{me.get('project') or '—'}» на других пунктах ({listed}) " f"— общие носители через common-lock") if orphans_without_item: lines.append(f"молчат дольше {ORPHAN_MIN} мин без сдачи и без пункта: {orphans_without_item} (подхватывать нечего; список: session-reg.py list --alive-hours 24)") if not lines: print(f"пересечений нет; живых сессий {live_count}") return print("\n".join(lines)) print(f"живых сессий {live_count}") def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser(description=__doc__) commands = root.add_subparsers(dest="command", required=True) op = commands.add_parser("open") op.add_argument("label"); op.add_argument("--surface", default="") op.add_argument("--subject", default=""); op.add_argument("--files", default=""); op.add_argument("--project", default="") op.add_argument("--item", default="", help="пункт плана проекта (P-NN)") touch = commands.add_parser("touch"); touch.add_argument("label"); touch.add_argument("--subject", default=""); touch.add_argument("--item", default="") done = commands.add_parser("done"); done.add_argument("label"); done.add_argument("text"); done.add_argument("--item", default=None) commands.add_parser("close").add_argument("label") pick = commands.add_parser("pickup", help="подхватить сироту: закрыть её в реестре, её пункт — подхватившей") pick.add_argument("label"); pick.add_argument("--by", required=True) listing = commands.add_parser("list"); listing.add_argument("--alive-hours", type=float, default=LIVE_HOURS) commands.add_parser("purge", help="убрать пробные метки по префиксу (в префиксе — «probe»)").add_argument("prefix") check = commands.add_parser("check"); check.add_argument("label") check.add_argument("--subject", default=""); check.add_argument("--files", default="") check.add_argument("--project", default=""); check.add_argument("--item", default="") return root def main() -> int: args = parser().parse_args() if args.command == "open": append("open", args.label, surface=args.surface, subject=args.subject, files=files_arg(args.files), project=args.project, item=args.item) print(f"сессия открыта: {args.label}") elif args.command == "touch": append("touch", args.label, subject=args.subject, item=args.item); print(f"сессия отмечена: {args.label}") elif args.command == "done": append("done", args.label, text=args.text, item=args.item); print(f"результат записан: {args.label}") elif args.command == "close": append("close", args.label); print(f"сессия закрыта: {args.label}") elif args.command == "pickup": orphan = states().get(args.label) or blank(args.label) append("pickup", args.label, by=args.by, item=orphan.get("item")) if orphan.get("item"): append("touch", args.by, item=orphan["item"]) print(f"сирота {args.label} подхвачена {args.by}; пункт: {orphan.get('item') or '—'}") elif args.command == "purge": print(f"убрано пробных записей: {purge(args.prefix)}") elif args.command == "list": print_list(args.alive_hours) elif args.command == "check": print_check(args.label, args.subject, files_arg(args.files), args.project, args.item) return 0 if __name__ == "__main__": raise SystemExit(main())