#!/usr/bin/env python3 """Борт свидетеля агента: запись КАЖДОГО дошедшего хода оператора и сдач эпизодов (agent-home-kit). Пара к сенсору на стороне оператора (если он есть). Дошедшее знаю только я; отправленное знает только он. Расхождение = потеря. usage: python3 bin/witness-log.py --surface cowork --session [--turn N] < text python3 bin/witness-log.py --surface cowork --session --role assistant --done "<предмет → результат>" --item <пункт плана|вне-плана> """ import sys, json, hashlib, argparse, datetime, pathlib, subprocess sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from kit import HOME # корень дома из kit.json (HOME_KIT_ROOT) LEDGER = HOME / "state/witness/turns.jsonl" LETTERS = HOME / "journal/letters" REG = str(HOME / "bin/session-reg.py") LETTER_MIN = 1500 # замер 16.10: письмо 06.10 — 4 КБ, сборка таблички меньше 1,5 КБ — отписка (порог по ТЗ letters-habit) COORD_PREFIXES = ("cw-", "chat-", "cha") # метки окон-координаторов (как в ветке --ack) def main(): p = argparse.ArgumentParser() p.add_argument("--surface", required=True) # поверхность: cowork | headless | api | tg … p.add_argument("--session", required=True) # метка сессии (моя, стабильная в пределах сессии) p.add_argument("--turn", type=int, default=None) p.add_argument("--attachments", type=int, default=0) p.add_argument("--client-session", default="") # cse_… из URL клиента: связка с бортом А p.add_argument("--role", default="user") # user | assistant (мой исходящий ход) p.add_argument("--done", default=None) p.add_argument("--item", default=None) # пункт плана проекта, напр. P-01 # сдача эпизода: одна строка «предмет → результат» # --ack: лёгкая запись «ход дошёл» без текста хода: только первые слова (<=24 знака). # Текст хода оператора в команду не переписывается (2026-09-02: повтор его текста в моих # командах — вероятный триггер внешнего фильтра). Сверка таких записей — по счёту ходов в сессии. p.add_argument("--ack", default=None) p.add_argument("--close", "--end", "--finish", dest="close", action="store_true") # закрытие сессии; координатору и тяжёлой — только с письмом p.add_argument("--letter", default=None) # путь письма journal/letters/*.md (cards/templates/letter.md) p.add_argument("--heavy", action="store_true") # тяжёлая сессия (не по метке) a = p.parse_args() if a.close: if a.session.startswith(COORD_PREFIXES) or a.heavy: lp = pathlib.Path(a.letter or "") if not a.letter or not lp.is_file() or lp.resolve().parent != LETTERS or lp.stat().st_size < LETTER_MIN: p.error(f"--close для сессии-координатора/тяжёлой требует --letter <файл journal/letters/*.md, >= {LETTER_MIN} Б>; закрытие без письма не проходит") rec = {"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "kind": "close", "role": a.role, "surface": a.surface, "session": a.session, "letter": a.letter or ""} LEDGER.parent.mkdir(parents=True, exist_ok=True) with LEDGER.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") print(f"witness: close session={a.session} letter={a.letter or '-'}") return if a.done is not None: if a.ack is not None: p.error("--done несовместим с --ack") if not a.done.strip() or "\n" in a.done or "\r" in a.done: p.error("--done требует одну непустую строку") # 07.09: сдача без пункта плана не попадала в план и возвращалась кругом — # 13 таких за 30 суток, число только росло. Пункт обязателен; когда сдача # действительно вне плана, это говорится словом: --item вне-плана. if not (a.item or "").strip(): p.error("--done требует --item <пункт плана> (или --item вне-плана, если сдача вне плана направления)") rec = { "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "kind": "done", "role": a.role, "surface": a.surface, "session": a.session, "text": a.done.strip(), "client_session": a.client_session, } LEDGER.parent.mkdir(parents=True, exist_ok=True) with LEDGER.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") subprocess.run([sys.executable, REG, "done", a.session, a.done.strip()] + (["--item", a.item] if a.item else []), check=True, capture_output=True, text=True) print(f"witness: done session={rec['session']}") return if a.ack is not None: rec = { "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "role": a.role, "surface": a.surface, "session": a.session, "turn": a.turn, "mode": "ack", "head": a.ack[:24], "attachments": a.attachments, "client_session": a.client_session, } LEDGER.parent.mkdir(parents=True, exist_ok=True) with LEDGER.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") subprocess.run([sys.executable, REG, "touch", a.session, "--subject", rec["head"]], capture_output=True, text=True) print(f"witness: ack turn={rec['turn']} {rec['client_session'] or '-'}") return text = sys.stdin.read() norm = text.strip() rec = { "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), "role": a.role, "surface": a.surface, "session": a.session, "turn": a.turn, "chars": len(norm), "sha256": hashlib.sha256(norm.encode("utf-8")).hexdigest(), "head": norm[:80], "tail": norm[-80:] if len(norm) > 160 else "", "attachments": a.attachments, "client_session": a.client_session, } LEDGER.parent.mkdir(parents=True, exist_ok=True) with LEDGER.open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") print(f"witness: {rec['sha256'][:12]} {rec['chars']}c turn={rec['turn']}") if __name__ == "__main__": main()