diff --git a/scripts/enable-plugins.js b/scripts/enable-plugins.js index 5b50e7d..1d5158c 100644 --- a/scripts/enable-plugins.js +++ b/scripts/enable-plugins.js @@ -91,6 +91,7 @@ if (!JSON.stringify(data.hooks.PreToolUse).includes("kvb-org-organize/scripts/co const SCRIPT_HOOKS = [ ["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch"], + ["kvb-org-organize/scripts/news.py", "UserPromptSubmit", null, "hook"], ["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch"], ["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block"], ["kvb-org-perform/scripts/log.py", "PreToolUse", "Agent|Task", "spawn"], diff --git a/skills/kvb-org-organize/scripts/news.py b/skills/kvb-org-organize/scripts/news.py new file mode 100755 index 0000000..0c3b721 --- /dev/null +++ b/skills/kvb-org-organize/scripts/news.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Share status news between agents without waking them: each session sees it with its next turn. + +Environment: + AGENTS_COUNCIL_ROOT: council directory; the news is /news.jsonl and + each session's read mark /news-seen/. + /tmp/agents-council unless set. + CLAUDE_CODE_SESSION_ID: the calling session. + +Args: + post "" [--to ...] [--session ]: add one item, for every + session or only the named ones. + hook: UserPromptSubmit hook; reads the hook JSON on stdin, prints the items + of the last 24 hours this session has not seen (at most 10, own posts + left out) and marks them seen. Never fails the prompt. + show [--all] [--session ]: print the unseen items, or every kept one, + without marking them. + +Exit codes: + 0 done, 2 usage error. +""" + +from __future__ import annotations + +import argparse +import datetime +import fcntl +import importlib.util +import json +import os +import sys +import tempfile +import time +from pathlib import Path +from types import ModuleType + +ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council")) +NEWS = ROOT / "news.jsonl" +SEEN = ROOT / "news-seen" +LOCK = ROOT / "news.lock" +COUNCIL = Path(__file__).resolve().with_name("council.py") +WINDOW = 86400 +SHOWN = 10 +KEPT = 500 + + +def fail(message: str) -> None: + print(f"news: {message}", file=sys.stderr) + sys.exit(2) + + +def council() -> ModuleType: + spec = importlib.util.spec_from_file_location("council", COUNCIL) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def items() -> list[dict]: + try: + lines = NEWS.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + rows = [] + for line in lines: + try: + row = json.loads(line) + except ValueError: + continue + if isinstance(row, dict) and isinstance(row.get("ts"), int | float): + rows.append(row) + return rows + + +def unseen(sid: str, name: str | None) -> list[dict]: + try: + mark = float((SEEN / sid).read_text(encoding="utf-8")) + except (OSError, ValueError): + mark = 0.0 + floor = max(mark, time.time() - WINDOW) + return [ + row + for row in items() + if row["ts"] > floor + and row.get("from") != name + and (not row.get("to") or name in row["to"]) + ] + + +def line(row: dict) -> str: + stamp = datetime.datetime.fromtimestamp(row["ts"]).strftime("%H:%M") + target = f" to {', '.join(row['to'])}" if row.get("to") else "" + return f"NEWS {stamp} {row.get('from', '?')}{target}: {row.get('text', '')}" + + +def cmd_post(args: argparse.Namespace, cn: ModuleType) -> int: + sid, _ = cn.caller(args.session) + text = " ".join(args.text.split()) + if not text: + fail("post needs a text") + entry = {"ts": time.time(), "from": cn.live_sessions().get(sid) or sid, "to": args.to or [], "text": text} + ROOT.mkdir(parents=True, exist_ok=True) + with LOCK.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + rows = [*items()[-(KEPT - 1):], entry] + with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle: + handle.write("".join(json.dumps(row) + "\n" for row in rows)) + os.replace(handle.name, NEWS) + print(f"posted to {', '.join(entry['to']) or 'all'}") + return 0 + + +def cmd_hook(_: argparse.Namespace, cn: ModuleType) -> int: + try: + payload = json.load(sys.stdin) + sid = payload.get("session_id") or os.environ.get("CLAUDE_CODE_SESSION_ID") + if not sid: + return 0 + rows = unseen(sid, cn.live_sessions().get(sid)) + if not rows: + return 0 + for row in rows[-SHOWN:]: + print(line(row)) + if len(rows) > SHOWN: + print(f"NEWS {len(rows) - SHOWN} older items: news.py show") + SEEN.mkdir(parents=True, exist_ok=True) + (SEEN / sid).write_text(str(rows[-1]["ts"]), encoding="utf-8") + except (OSError, ValueError, AttributeError): + return 0 + return 0 + + +def cmd_show(args: argparse.Namespace, cn: ModuleType) -> int: + if args.all: + rows = items() + else: + sid, _ = cn.caller(args.session) + rows = unseen(sid, cn.live_sessions().get(sid)) + for row in rows: + print(line(row)) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("post") + p.add_argument("text") + p.add_argument("--to", action="append") + p.add_argument("--session") + p.set_defaults(func=cmd_post) + sub.add_parser("hook").set_defaults(func=cmd_hook) + p = sub.add_parser("show") + p.add_argument("--all", action="store_true") + p.add_argument("--session") + p.set_defaults(func=cmd_show) + args = parser.parse_args() + return args.func(args, council()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dream.py b/tests/test_dream.py index f56c531..bf92f3b 100644 --- a/tests/test_dream.py +++ b/tests/test_dream.py @@ -155,6 +155,7 @@ class TestSettingsRegistration(unittest.TestCase): self.assertEqual(gate.count("kvb-org-organize/scripts/council.py"), 1) self.assertEqual(gate.count("kvb-org-organize/scripts/watch.py"), 1) self.assertEqual(entries.count("kvb-org-organize/scripts/watch.py"), 1) + self.assertEqual(entries.count("kvb-org-organize/scripts/news.py"), 1) post = json.dumps(data["hooks"]["PostToolUse"]) self.assertEqual(post.count("kvb-org-organize/scripts/watch.py"), 1) self.assertEqual(gate.count("kvb-org-perform/scripts/log.py"), 1) diff --git a/tests/test_news.py b/tests/test_news.py new file mode 100644 index 0000000..ec7b12d --- /dev/null +++ b/tests/test_news.py @@ -0,0 +1,61 @@ +"""Exercise news.py: post, the hook's once-only delivery, addressing, own posts, and a broken hook input.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +NEWS = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "news.py" + + +class TestNews(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.home = Path(tmp.name) / "home" + self.root = Path(tmp.name) / "council" + sessions = self.home / ".claude" / "sessions" + sessions.mkdir(parents=True) + for sid, name in (("s1", "One"), ("s2", "Two"), ("s3", "Three")): + (sessions / f"{sid}.json").write_text(json.dumps({"sessionId": sid, "name": name}), encoding="utf-8") + + def _run(self, *args: str, sid: str | None = "s1", stdin: str = "") -> subprocess.CompletedProcess: + env = {"PATH": "/usr/bin:/bin", "HOME": str(self.home), "AGENTS_COUNCIL_ROOT": str(self.root)} + if sid: + env["CLAUDE_CODE_SESSION_ID"] = sid + return subprocess.run( + [sys.executable, str(NEWS), *args], input=stdin, capture_output=True, text=True, env=env, check=False + ) + + def _hook(self, sid: str) -> list[str]: + result = self._run("hook", sid=None, stdin=json.dumps({"session_id": sid, "prompt": "x"})) + self.assertEqual(result.returncode, 0, result.stderr) + return [line.split(" ", 2)[2] for line in result.stdout.splitlines()] + + def test_every_session_sees_a_post_once_except_its_author(self): + self.assertEqual(self._run("post", "core #1358 merged").stdout, "posted to all\n") + self.assertEqual(self._hook("s2"), ["One: core #1358 merged"]) + self.assertEqual(self._hook("s2"), []) + self.assertEqual(self._hook("s3"), ["One: core #1358 merged"]) + self.assertEqual(self._hook("s1"), []) + + def test_addressed_post_reaches_only_its_target(self): + self._run("post", "your gate is granted", "--to", "Two") + self.assertEqual(self._hook("s3"), []) + self.assertEqual(self._hook("s2"), ["One to Two: your gate is granted"]) + self.assertEqual(len(self._run("show", "--all").stdout.splitlines()), 1) + + def test_hook_never_fails_and_post_needs_a_session(self): + self.assertEqual((self._run("hook", sid=None, stdin="not json").returncode), 0) + self.assertEqual(self._run("hook", sid=None, stdin="{}").stdout, "") + self.assertEqual(self._run("post", "x", sid=None).returncode, 2) + self.assertEqual(self._run("post", " ").returncode, 2) + + +if __name__ == "__main__": + unittest.main()