feat(kvb-org-organize): add a news board agents read without being woken
Every delivered SendMessage costs its recipient a full turn of re-reading its context. news.py keeps status that asks nothing in <council root>/news.jsonl: post adds an item for every session or --to named ones (at most 500 kept, under a lock), and its UserPromptSubmit hook prints the unseen items of the last 24 hours (at most 10, own posts left out) with the session's next turn and marks them seen per session; the hook never fails a prompt. show prints without marking. enable-plugins.js registers the hook; test_news covers single delivery, addressing, own posts and broken input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76c4d32bc9
commit
48ae64c932
4 files changed
+225
No files matched your search
@@ -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"],
|
||||
|
||||
Executable
+162
@@ -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 <root>/news.jsonl and
|
||||
each session's read mark <root>/news-seen/<session id>.
|
||||
/tmp/agents-council unless set.
|
||||
CLAUDE_CODE_SESSION_ID: the calling session.
|
||||
|
||||
Args:
|
||||
post "<text>" [--to <name> ...] [--session <id>]: 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 <id>]: 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user