292 lines
15 KiB
Python
Executable file
292 lines
15 KiB
Python
Executable file
#!/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())
|