384 lines
17 KiB
Python
Executable file
384 lines
17 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
"""Лестница пробуждения — контекст подаётся ступенями, не пачкой (agent-home-kit; исходник — дом Дистилла, v0.1 2026-09-02).
|
||
|
||
Каждая ступень — отдельный вызов, отдельный кусок (<= max байт), по желанию проверенный
|
||
линтером (kit.json: lint_cmd), с записью в журнал. Если сессия оборвалась, журнал
|
||
показывает последнюю доставленную ступень — это и есть подозреваемый; ступень
|
||
выключается одной командой.
|
||
|
||
wake-ladder.py next <session> [--project <имя>] выдать следующую ступень (и записать в журнал)
|
||
wake-ladder.py step <session> <id> выдать конкретную ступень (в т.ч. manual)
|
||
wake-ladder.py trace <session> путь пройденных ступеней
|
||
wake-ladder.py last путь последней сессии (где остановилось)
|
||
wake-ladder.py list ступени, состояние, лимиты (без содержимого)
|
||
wake-ladder.py off <id> [причина] / on <id> выключить/включить ступень
|
||
wake-ladder.py dry сухой прогон: все ступени, размеры
|
||
|
||
Манифест: <дом>/wake/ladder.json. Журнал: <дом>/state/wake/ladder/<session>.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 <session> 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 <session> --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:]))
|