#!/usr/bin/env python3 """Лестница пробуждения — контекст подаётся ступенями, не пачкой (agent-home-kit; исходник — дом Дистилла, v0.1 2026-09-02). Каждая ступень — отдельный вызов, отдельный кусок (<= max байт), по желанию проверенный линтером (kit.json: lint_cmd), с записью в журнал. Если сессия оборвалась, журнал показывает последнюю доставленную ступень — это и есть подозреваемый; ступень выключается одной командой. wake-ladder.py next [--project <имя>] выдать следующую ступень (и записать в журнал) wake-ladder.py step выдать конкретную ступень (в т.ч. manual) wake-ladder.py trace путь пройденных ступеней wake-ladder.py last путь последней сессии (где остановилось) wake-ladder.py list ступени, состояние, лимиты (без содержимого) wake-ladder.py off [причина] / on выключить/включить ступень wake-ladder.py dry сухой прогон: все ступени, размеры Манифест: <дом>/wake/ladder.json. Журнал: <дом>/state/wake/ladder/.jsonl Виды ступеней (поле kind): file, goal, goal-full, project, mail, cmd, dispatch. """ from __future__ import annotations import json import re import shlex import subprocess import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from kit import CFG, HOME, AGENT, cli # noqa: E402 MANIFEST = HOME / "wake" / "ladder.json" JOURNAL_DIR = HOME / "state" / "wake" / "ladder" MIRROR_DIR = HOME / "state" / "wake" / "mirror" def load() -> dict: return json.loads(MANIFEST.read_text(encoding="utf-8")) def save(m: dict) -> None: MANIFEST.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def _probe(cmd: str, text: str) -> tuple[bool, str]: """Внешний прибор ступени: команда читает stdin, rc=0 — чисто. Пустая команда — прибора нет, ступень проходит.""" if not cmd: return True, "" try: p = subprocess.run(shlex.split(cmd), input=text, text=True, capture_output=True, timeout=30, cwd=str(HOME)) except Exception as exc: # прибор назначен, но сломан — ступень не выдаём, это не «ok» return False, f"прибор недоступен: {exc}" if p.returncode == 0: return True, "" lines = [l for l in (p.stdout + p.stderr).splitlines() if l.strip()] return False, "\n".join(lines[:4]) def lint(text: str) -> tuple[bool, str]: return _probe(CFG.get("lint_cmd", ""), text) def density_ok(text: str) -> tuple[bool, str]: return _probe(CFG.get("density_cmd", ""), text) # ---- источники ступеней ----------------------------------------------------- def src_file(step: dict) -> str: return (HOME / step["src"]).read_text(encoding="utf-8").strip() PROJECT_MAP = HOME / "tasks" / "active" / "projects" / "MAP.md" def session_project(session: str) -> str: """Проект, названный сессией при open (session-reg), или ''.""" ev = HOME / "state" / "sessions" / "events.jsonl" proj = "" if ev.exists(): for line in ev.read_text(encoding="utf-8").splitlines(): try: r = json.loads(line) except Exception: continue if r.get("label") == session and r.get("kind") == "open" and r.get("project"): proj = str(r["project"]) return proj def anchor_file(session: str) -> tuple[Path, str]: """(файл якоря, подпись). Проект из MAP.md (строки «имя | путь»); не назван или не найден → ACTIVE-TASK.md.""" proj = session_project(session) if proj and PROJECT_MAP.exists(): for line in PROJECT_MAP.read_text(encoding="utf-8").splitlines(): if "|" in line and not line.startswith("#"): name, rel = [x.strip() for x in line.split("|", 1)] if name.lower() == proj.lower(): return HOME / rel, f"{rel} (проект «{proj}»)" return HOME / "ACTIVE-TASK.md", "ACTIVE-TASK.md (общий якорь)" def src_goal(step: dict) -> str: """ЦЕЛЬ + число «следующих шагов» из якоря, без реквизитов и деталей.""" f, label = anchor_file(step.get("_session", "")) if not f.exists(): return f"Активной задачи нет ({label} отсутствует)." raw = re.sub(r"", "", f.read_text(encoding="utf-8"), flags=re.S) age_h = (time.time() - f.stat().st_mtime) / 3600 out = [f"({label}, обновлён ~{age_h:.0f} ч назад" + (", ПРОТУХ >48ч" if age_h > 48 else "") + ")"] for line in raw.splitlines(): s = line.strip() if s.startswith("# ") or s.upper().startswith("ЦЕЛЬ"): out.append(s) m = re.search(r"##[^\n]*[Сс]ледующие шаги[^\n]*\n(.*?)(?:\n##|\Z)", raw, flags=re.S) n = len(re.findall(r"^\s*\d+\.\s+", m.group(1), flags=re.M)) if m else 0 out.append(f"Следующих шагов: {n}. Полный текст — ступенью `step goal-full`, только если он нужен для дела.") return "\n".join(out) def src_goal_full(step: dict) -> str: f, _ = anchor_file(step.get("_session", "")) return re.sub(r"", "", f.read_text(encoding="utf-8"), flags=re.S).strip() if f.exists() else "нет" def _run(cmd: list[str], timeout: int = 40) -> str: try: p = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, cwd=str(HOME)) return (p.stdout or p.stderr).strip() except Exception as exc: return f"{cmd[1] if len(cmd) > 1 else cmd[0]}: не отработал ({type(exc).__name__})" def src_project(step: dict) -> str: """Якорь проекта, в котором открыта сессия: полный файл, не выжимка.""" session = step.get("_session", "") proj = session_project(session) if not proj: return "проект не назван → wake-ladder.py next --project <имя> (действует на любом вызове)" f, label = anchor_file(session) if label.endswith("(общий якорь)"): # строки проекта в карте нет — якорь по умолчанию return f"проект «{proj}» не в карте tasks/active/projects/MAP.md — добавить строку «{proj} | <путь якоря>» и файл якоря" body = re.sub(r"", "", f.read_text(encoding="utf-8"), flags=re.S).strip() if f.exists() else "файл якоря отсутствует" age_h = (time.time() - f.stat().st_mtime) / 3600 if f.exists() else 0 return f"({label}, обновлён ~{age_h:.0f} ч назад)\n{body}" def src_mail(step: dict) -> str: """Корреспонденция: письма шины сессий этой сессии + сдачи диспетчера. Шина первой: ступень режется по max, хвост срезается.""" session = step.get("_session", "") out = [_run([sys.executable, "bin/session-bus.py", "inbox", "--session", session]), _run([sys.executable, "bin/dispatch.py", "mail"])] return "\n".join(x for x in out if x.strip()) def src_cmd(step: dict) -> str: """Любой прибор дома: поле cmd (строка, запуск из корня дома), timeout — потолок в секундах.""" return _run(shlex.split(step["cmd"]), timeout=int(step.get("timeout", 40))) def is_coord(session: str) -> bool: cp = CFG.get("coord_project", "") return bool(cp) and session_project(session).lower() == cp.lower() def src_dispatch(step: dict) -> str: """Сводка диспетчера (окну-координатору): что разнесено и что сдано.""" return _run([sys.executable, "bin/dispatch.py", "list", "--open"], timeout=30) SOURCES = {"file": src_file, "goal": src_goal, "goal-full": src_goal_full, "project": src_project, "mail": src_mail, "cmd": src_cmd, "dispatch": src_dispatch} def render(step: dict) -> tuple[str, dict]: """Содержимое ступени и запись для журнала.""" fn = SOURCES[step.get("kind", "file")] try: text = fn(step) except Exception as exc: text = f"ступень не собралась: {exc}" mx = int(step.get("max", 1500)) cut = len(text.encode("utf-8")) > mx if cut: b = text.encode("utf-8")[:mx] text = b.decode("utf-8", errors="ignore") + f"\n[обрезано до {mx} Б, полный источник: {step.get('src') or 'wake-ladder.py step ' + step.get('_session', '<сессия>') + ' ' + step['id']}]" ok, report = (True, "") if not step.get("lint", True) else lint(text) dok, dreport = density_ok(text) if ok else (True, "") rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "step": step["id"], "bytes": len(text.encode("utf-8")), "lint": "ok" if ok else "hits", "density": "ok" if dok else "over", "cut": cut} if not ok or not dok: text = (f"СТУПЕНЬ УДЕРЖАНА прибором (не выдана в контекст). Отчёт:\n{report or dreport}\n" f"Поправь источник ({step.get('src', step.get('kind'))}) или выключи ступень: wake-ladder.py off {step['id']}") return text, rec def journal_path(session: str) -> Path: JOURNAL_DIR.mkdir(parents=True, exist_ok=True) return JOURNAL_DIR / (re.sub(r"[^A-Za-z0-9_.-]", "_", session) + ".jsonl") def done_steps(session: str) -> list[dict]: p = journal_path(session) if not p.exists(): return [] return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] def enabled(m: dict, session: str = "") -> list[dict]: """coord_only — ступень только окну-координатору (kit.json: coord_project).""" return [s for s in m["steps"] if s.get("on", True) and not s.get("manual") and (not s.get("coord_only") or (session and is_coord(session)))] def mirror(session: str, step: dict, idx: int, total: int, text: str) -> None: """Зеркало окна пробуждения: тело ступени рядом с журналом — окно читается и после сессии.""" try: d = MIRROR_DIR / re.sub(r"[^A-Za-z0-9_.-]", "_", session) d.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) head = f"# ступень {idx}/{total} · {step['id']} · {stamp}\n\n" (d / f"{idx:02d}-{step['id']}.md").write_text(head + text + "\n", encoding="utf-8") (MIRROR_DIR / "LAST.md").write_text( f"# окно пробуждения — {session}, ступень {idx}/{total} ({step['id']}), {stamp}\n\n" + text + "\n", encoding="utf-8") except Exception: pass # зеркало не несущее: его отказ не должен ломать пробуждение def emit(session: str, step: dict, idx: int, total: int) -> None: text, rec = render({**step, "_session": session}) rec["session"] = session with journal_path(session).open("a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") mirror(session, step, idx, total, text) kb = rec["bytes"] / 1024 print(f"── СТУПЕНЬ {idx}/{total} {step['id']} ({kb:.1f} КБ, линтер {rec['lint']}) — {step.get('title','')} ──") print(text) if idx and idx < total: print(f"── дальше: {cli('wake-ladder.py')} next {session} ──") elif idx: print(f"── ЛЕСТНИЦА ПРОЙДЕНА ({total} ступеней). Путь: wake-ladder.py trace {session} ──") def bind_project(session: str, project: str) -> None: """--project действует на ЛЮБОМ вызове, не только на первом.""" if project and session_project(session).lower() != project.lower(): _run([sys.executable, "bin/session-reg.py", "open", session, "--project", project]) def cmd_next(session: str, project: str = "") -> int: m = load() bind_project(session, project) steps = enabled(m, session) seen = {r["step"] for r in done_steps(session)} first = not seen for i, s in enumerate(steps, 1): if s["id"] not in seen: if first: _run([sys.executable, "bin/session-reg.py", "open", session, "--project", project]) print(f"{AGENT}: пробуждение {session}") emit(session, s, i, len(steps)) if first: print(_run([sys.executable, "bin/session-reg.py", "check", session])) return 0 print(f"лестница {session} уже пройдена ({len(steps)} ступеней); trace {session} покажет путь") return 0 def cmd_step(session: str, sid: str, project: str = "") -> int: m = load() bind_project(session, project) for s in m["steps"]: if s["id"] == sid: emit(session, s, 0, 0) return 0 print(f"нет ступени {sid}") return 1 def fmt_trace(session: str) -> str: rows = done_steps(session) if not rows: return f"{session}: журнала нет" out = [f"{session}: {len(rows)} ступеней"] for r in rows: out.append(f" {r['ts']} {r['step']:<16} {r['bytes']:>6} Б линтер={r['lint']}" + (" ОБРЕЗАНО" if r.get("cut") else "")) m = load() left = [s["id"] for s in enabled(m, session) if s["id"] not in {r["step"] for r in rows}] out.append(" не дошли: " + (", ".join(left) if left else "—")) out.append(f" последняя доставленная: {rows[-1]['step']} — если сессию остановило после неё, подозреваемый она") return "\n".join(out) def cmd_last() -> int: JOURNAL_DIR.mkdir(parents=True, exist_ok=True) files = sorted(JOURNAL_DIR.glob("*.jsonl"), key=lambda p: p.stat().st_mtime) if not files: print("журналов нет") return 0 print(fmt_trace(files[-1].stem)) return 0 def cmd_list() -> int: m = load() for s in m["steps"]: flag = "on" if s.get("on", True) else "OFF" if s.get("manual"): flag = "man" note = s.get("src", s.get("cmd", s.get("kind", ""))) if not s.get("on", True) and s.get("off_reason"): note += f" [выкл: {s['off_reason']}]" print(f"{s['id']:<16} {flag:<4} {s.get('kind','file'):<9} {s.get('max',1500):>5} {note}") return 0 def cmd_toggle(sid: str, on: bool, reason: str) -> int: m = load() for s in m["steps"]: if s["id"] == sid: s["on"] = on if not on: s["off_reason"] = reason or time.strftime("%Y-%m-%d") else: s.pop("off_reason", None) save(m) print(f"{sid}: {'включена' if on else 'выключена'}") return 0 print(f"нет ступени {sid}") return 1 def cmd_dry() -> int: m = load() total = bad = 0 for i, s in enumerate(m["steps"], 1): text, rec = render(s) total += rec["bytes"] state = "on " if s.get("on", True) else "OFF" if s.get("manual"): state = "man" mark = "" if rec["lint"] == "ok" else " <- УДЕРЖАНА" bad += rec["lint"] != "ok" print(f"{i:>2}. [{state}] {s['id']:<16} {rec['bytes']:>6} Б линтер={rec['lint']}{' обрезано' if rec['cut'] else ''}{mark}") print(f"итого {total/1024:.1f} КБ по всем ступеням; удержано линтером: {bad}") return 1 if bad else 0 def main(argv: list[str]) -> int: if not argv or argv[0] in ("-h", "--help"): print(__doc__) return 0 c = argv[0] project = "" if "--project" in argv: i = argv.index("--project") project = argv[i + 1] if i + 1 < len(argv) else "" del argv[i:i + 2] if c == "next" and len(argv) > 1: return cmd_next(argv[1], project) if c == "step" and len(argv) > 2: return cmd_step(argv[1], argv[2], project) if c == "trace" and len(argv) > 1: print(fmt_trace(argv[1])) return 0 if c == "last": return cmd_last() if c == "list": return cmd_list() if c == "off" and len(argv) > 1: return cmd_toggle(argv[1], False, " ".join(argv[2:])) if c == "on" and len(argv) > 1: return cmd_toggle(argv[1], True, "") if c == "dry": return cmd_dry() print(__doc__) return 2 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))