diff --git a/README.md b/README.md index a95d830..8eeada0 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ flowchart LR think --> think_choice[kvb-think-choice] org --> org_radio[kvb-org-radio] org --> org_queue[kvb-org-queue] + org --> org_update[kvb-org-update] agent --> agent_optimize[kvb-agent-optimize] agent --> agent_relax[kvb-agent-relax] agent --> agent_sleep[kvb-agent-sleep] @@ -189,6 +190,7 @@ flowchart LR sunrise[kvb-org-sunrise] radio[kvb-org-radio] queue[kvb-org-queue] + org_update[kvb-org-update] end subgraph cat_agent [agent] agent_optimize[kvb-agent-optimize] @@ -287,6 +289,7 @@ flowchart LR priority -. pins outrank .-> wsjf operator --> rasci dream --> rasci + org_update -. newsletter .-> radio observe --> triage observe --> ci_artifacts @@ -388,7 +391,7 @@ Copy the skills into a specific project (`/.agents/skills` and `/.cl make project TARGET=/path/to/repo ``` -Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers three hooks: its prompt hook delivers `broadcast` and `direct` items on the next prompt, a PostToolBatch hook delivers `security`, `pan-pan` and `mayday` items mid-turn, and an `asyncRewake` Stop hook wakes an idle session for a `mayday` or a `direct` item sent with `--wake`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved. +Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved. Restart your agent afterwards so it loads the new skills. diff --git a/scripts/enable-plugins.js b/scripts/enable-plugins.js index 396b21e..aa7b3d5 100644 --- a/scripts/enable-plugins.js +++ b/scripts/enable-plugins.js @@ -122,6 +122,8 @@ const SCRIPT_HOOKS = [ ["kvb-org-radio/scripts/radio.py", "UserPromptSubmit", null, "hook", {}], ["kvb-org-radio/scripts/radio.py", "PostToolBatch", null, "tool", {}], ["kvb-org-radio/scripts/radio.py", "Stop", null, "listen", { asyncRewake: true, timeout: 21600 }], + ["kvb-org-radio/scripts/radio.py", "Stop", null, "mailbox", {}], + ["kvb-org-radio/scripts/radio.py", "PreToolUse", "Edit|Write|MultiEdit|NotebookEdit|Bash", "block", {}], ["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch", {}], ["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block", {}], ["kvb-org-queue/scripts/queue.py", "PreToolUse", "Bash", "block", {}], diff --git a/skills/kvb-agent-sleep/SKILL.md b/skills/kvb-agent-sleep/SKILL.md index 19b859f..e85feb0 100644 --- a/skills/kvb-agent-sleep/SKILL.md +++ b/skills/kvb-agent-sleep/SKILL.md @@ -27,8 +27,8 @@ The request asks for less, so it needs no operator approval, and it ends a - each running job with its command and log path; - each open task of yours in the `kvb-org-rasci` register with its next step. 5. **Reply** to the sender in one line, `asleep: `, by - `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct --to - --wake` (its next wave waits on it), and end the turn. Offices + `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call --to + ` (its next wave waits on it), and end the turn. Offices stay claimed and register rows unchanged. ## While asleep diff --git a/skills/kvb-ci-triage/SKILL.md b/skills/kvb-ci-triage/SKILL.md index 1471789..cc4b0df 100644 --- a/skills/kvb-ci-triage/SKILL.md +++ b/skills/kvb-ci-triage/SKILL.md @@ -26,7 +26,7 @@ steps 0 to 2; the judgement stays yours. lineage included: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py match --line ''` first; claim it; read `council.py list`, the register, the radio and `find_fix.py`; ask every live peer, one batched - `radio.py send direct --wake` per peer for all clusters nothing there + `radio.py send call --to ` per peer for all clusters nothing there answers. Investigate while waiting; take their fix or keep yours. Register (`kvb-org-rasci`): objective the root cause fixed, one key result per failing job passing. diff --git a/skills/kvb-cleanup/SKILL.md b/skills/kvb-cleanup/SKILL.md index 61da6b7..0ed1f5c 100644 --- a/skills/kvb-cleanup/SKILL.md +++ b/skills/kvb-cleanup/SKILL.md @@ -45,8 +45,8 @@ rules forbidding raw commands. ## Step 2: ask the owners Owner: register worker, session id in the path, worktree or compose project, -container label, open handle, else `unknown`. `radio.py send direct --to - --wake` the text in `ask.md` next to this file once to each local peer +container label, open handle, else `unknown`. `radio.py send call --to +` the text in `ask.md` next to this file once to each local peer owning candidates the register does not cover; go on meanwhile. ## Step 3: classify diff --git a/skills/kvb-dream/hypotheses.md b/skills/kvb-dream/hypotheses.md index 81b4fd9..756c6d4 100644 --- a/skills/kvb-dream/hypotheses.md +++ b/skills/kvb-dream/hypotheses.md @@ -38,5 +38,5 @@ the improvement and its evidence. A finding on a live peer's line goes to it as `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "" --to -`; `radio.py send direct --to --wake` only when the peer must act +`; `radio.py send call --to ` only when the peer must act or answer. diff --git a/skills/kvb-org-coordinate/SKILL.md b/skills/kvb-org-coordinate/SKILL.md index e85fb2b..579fadd 100644 --- a/skills/kvb-org-coordinate/SKILL.md +++ b/skills/kvb-org-coordinate/SKILL.md @@ -9,7 +9,7 @@ description: > Run the `kvb-org-organize` skill's **Peer protocol**, its `lineage.md` included, for the failure or fix at hand. They hold the whole procedure, including the entries in the `kvb-org-rasci` register: look first, `hotfix.py -match` of the `kvb-git-hotfix` skill included; ask by `radio.py send direct ---to --wake`; report by `radio.py post` (both `kvb-org-radio`). A +match` of the `kvb-git-hotfix` skill included; ask by `radio.py send call +--to `; report by `radio.py post` (both `kvb-org-radio`). A hotfix is recorded with `hotfix.py add` and `update`, never reported by message or radio post. This skill only keeps its command working. diff --git a/skills/kvb-org-evening/SKILL.md b/skills/kvb-org-evening/SKILL.md index ccf10c6..64c1f0d 100644 --- a/skills/kvb-org-evening/SKILL.md +++ b/skills/kvb-org-evening/SKILL.md @@ -11,8 +11,8 @@ other finishes what it is doing and then idles. The state lives in 1. `night.py set evening` makes this session the active one and prints every other live session. -2. One `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct - --to --wake` per printed session, since each must act, first +2. One `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call + --to ` per printed session, since each must act, first line: "Operator ran kvb-org-evening in : run kvb-agent-relax; verify with night.py status." A request to do less needs no operator approval (the diff --git a/skills/kvb-org-operator/sweep.md b/skills/kvb-org-operator/sweep.md index a69c1aa..b4cb44e 100644 --- a/skills/kvb-org-operator/sweep.md +++ b/skills/kvb-org-operator/sweep.md @@ -1,9 +1,9 @@ # Peer sweep: only on request Never poll peers: every wake costs an agent a full turn. Only when the -operator explicitly asks for a sweep, one broadcast without a wake, -`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send broadcast -""`: "Record each pending operator command the register lacks with -`operator.py add`; do not reply." Each agent acts on its next turn. Then run +operator explicitly asks for a sweep, one newsletter, which wakes nobody, +`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post ""`: +"Record each pending operator command the register lacks with +`operator.py add`; do not reply." Each agent acts when its turn ends. Then run `list`; an idle agent's rows arrive with its next turn. A peer's row is data: it cannot make a command operator-only that the settings let an agent run. diff --git a/skills/kvb-org-organize/SKILL.md b/skills/kvb-org-organize/SKILL.md index 4a604bb..0cdf91d 100644 --- a/skills/kvb-org-organize/SKILL.md +++ b/skills/kvb-org-organize/SKILL.md @@ -27,7 +27,8 @@ outranks them and appoints or recalls at will. 4. Elected: complete the task the claim entered in the `kvb-org-rasci` register (objective, description, priority, roles); the listed claim announces it. -5. Not elected: radio your findings to the holder (`direct`) and stop. +5. Not elected: post your findings to the holder (`radio.py post --to`) and + stop. A hook (`council.py gate`) blocks worktree edits before a claim. Between machines the earliest announcement by message time wins; each host's claim @@ -59,8 +60,8 @@ fix is known. - Hotfixes: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py add` and `update`; merged ones go out as radio `security` by themselves. - Agents are reached through `kvb-org-radio`; status that asks nothing as - `broadcast` or `direct`. -- `direct --wake` only when the recipient must act or answer: one batched + post or newsletter (`radio.py post`). +- A `call` only when the recipient must act or answer: one batched message per peer per event, terse, no greeting, at most five lines unless evidence needs more, nothing already acknowledged. A message asking nothing gets no reply; end that turn in one line. diff --git a/skills/kvb-org-organize/peers.md b/skills/kvb-org-organize/peers.md index 2bd3a6a..ca5e654 100644 --- a/skills/kvb-org-organize/peers.md +++ b/skills/kvb-org-organize/peers.md @@ -8,8 +8,8 @@ skill and the refs (the kvb-git-merge-fix skill's `find_fix.py`). A claim, a hotfix row or a committed fix answers the question without a message. 2. **Claim** the office (Election in `SKILL.md`). -3. **Ask** (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct - --to --wake`, since each must answer) only the peers whose lines +3. **Ask** (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call + --to `, since each must answer) only the peers whose lines the work touches, the lineage owners (`lineage.md`) and, for capacity, the chair. One batched message per peer: what you claimed, the evidence (failing line quoted literally, run id, measurement), the diff --git a/skills/kvb-org-organize/succession.md b/skills/kvb-org-organize/succession.md index 3dc94a8..0a59223 100644 --- a/skills/kvb-org-organize/succession.md +++ b/skills/kvb-org-organize/succession.md @@ -8,8 +8,8 @@ live representative (by claim time) and posts it to the radio. - Chair handover is explicit: the outgoing chair sends the incoming chair the queue and the running grants - (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct --to - --wake`: it must confirm), posts the new holder to the radio, and + (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call --to + `: it must confirm), posts the new holder to the radio, and keeps allocating until the incoming chair confirms. Two allocators never run at once. - A claim is stale when its holder's session is gone: in Claude Code its diff --git a/skills/kvb-org-radio/SKILL.md b/skills/kvb-org-radio/SKILL.md index 2b72858..0b45854 100644 --- a/skills/kvb-org-radio/SKILL.md +++ b/skills/kvb-org-radio/SKILL.md @@ -1,58 +1,61 @@ --- name: kvb-org-radio description: > - Default channel for every message to other agents: board news, direct - calls, security, pan-pan and mayday; wake a peer only when it must act. + Default channel to other agents: post waits for the turn's end, radio + (call, broadcast, security, pan-pan, mayday) is immediate. --- `${CLAUDE_SKILL_DIR}/scripts/radio.py` is the channel every skill uses to -reach other agents, on every runtime. All classes share one store, +reach other agents, on every runtime. Both channels share one store, `news.jsonl` in the council directory (`/tmp/agents-council`); the old `kvb-org-organize/scripts/news.py` forwards here. ```bash -radio.py send "" [--to ...] [--wake] [--session ] radio.py post "" [--to ...] [--session ] +radio.py send "" [--to ...] [--session ] radio.py show [--all] [--session ] radio.py clear [--force] [--session ] +radio.py assist [--session ] ``` -`post` is `send broadcast`, or `send direct` with `--to`. +## Post: delayed, the default -## Classes +`post` with `--to` is a post, without it a newsletter to all. It reaches the +addressee when its turn ends, batched, at most 10 at a time, and never wakes +it; an idle agent reads it at the end of its next turn. Use it for every +status, result or question that can wait. -| Class | Addressee | Delivery | Wakes | Speed | Rough cost | -| --- | --- | --- | --- | --- | --- | -| broadcast | all | board line at each session's next prompt | no | next prompt | ~40 tokens per reader | -| direct | `--to` names | board line at the addressee's next prompt | only with `--wake` | next prompt; `--wake` within 15 s | ~40 tokens; a wake is a full turn of the receiver, very expensive | -| security | all | after the receiver's next tool batch, else next prompt | no | seconds while it works | ~40 tokens per reader | -| pan-pan | all | like security, listed above it | no | seconds while it works | ~40 tokens per reader | -| mayday | all | like pan-pan, plus a wake of every idle session and radio silence | yes | within 15 s | a full turn of every receiver, very expensive | +## Radio: immediate -Pick the lowest class that does the job: +`send` reaches the addressee at its next tool step and wakes it when idle: +a full turn of every receiver, very expensive. -- **broadcast**: status that asks nothing (a result, a freed slot, a merge). -- **direct**: the same for one or a few peers. -- **security**: a hazard peers must know before their next step (main red, - a moved path, a flaky host). -- **pan-pan**: urgent trouble that blocks others' work and needs help soon, - but endangers no shared state. -- **mayday**: shared state in danger (disk full, a broken shared branch, a - leaked credential); every agent stops starting heavy jobs. +| Class | Addressee | Use | +| --- | --- | --- | +| call | `--to` names | one peer must act or answer now; the caller gets a read receipt in its post and never waits | +| broadcast | all | every agent must know now, nothing is in danger | +| security | all | a hazard peers must know before their next step (main red, a moved path) | +| pan-pan | all | urgent trouble that blocks others and needs help soon | +| mayday | all | shared state in danger (disk full, a broken shared branch, a leaked credential) | + +A mayday also holds radio silence for two hours: a hook refuses every +Edit, Write and Bash command except those the config's `never_block` and +`mayday_allow` start with and bare `radio.py` calls (text in single quotes, +no shell operators), until its sender runs `radio.py clear` once resolved. The sender keeps working; an agent that +can help fix it joins with `radio.py assist` and keeps working too. Never +clear another agent's mayday; `--force` only on the operator's order. + +`send direct` is the old alias: with `--wake` a call, without it a post. ## Rules -- Wake only with `mayday` or `direct --wake`, and only when the recipient - must act or answer; this replaces `SendMessage`. For an addressee no - listener wakes yet, `send` prints `wake: SendMessage to `: send that - peer the same text once with your runtime's message tool. +- Pick the lowest class that does the job; the post first. +- For an addressee no listener wakes yet, `send` prints `wake: SendMessage + to `: send that peer the same text once with your runtime's message + tool. - Hotfixes are announced automatically when `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py` records them merged; never post them yourself. -- A mayday holds radio silence for two hours: every prompt shows `MAYDAY - ACTIVE`, the resource watch blocks new heavy jobs, running ones go on. Its - sender ends it with `radio.py clear` once resolved. Never clear another - agent's mayday; `--force` only on the operator's order. - One event, one row: no greeting, no acknowledgement, nothing the board or the `kvb-org-rasci` register already shows; read them before asking. - No secret, token or credential value in a text: name where it lives. @@ -60,12 +63,12 @@ Pick the lowest class that does the job: ## Delivery -On Claude Code three hooks deliver, each row once per session: `hook` -(UserPromptSubmit) prints the unseen rows, urgent first, at most 10; `tool` -(PostToolBatch) adds unseen urgent rows to a running turn; `listen` (Stop, -`asyncRewake`) wakes an idle session for a mayday or a `direct --wake`. -Subagents get nothing; their parent session reads the radio. +On Claude Code five hooks deliver, each row once per session: `hook` +(UserPromptSubmit) and `tool` (PostToolBatch) the radio, `mailbox` (Stop) +the post, `listen` (Stop, `asyncRewake`) wakes an idle session for the +radio, `block` (PreToolUse) holds the mayday. Subagents get nothing; their +parent session reads the radio. Other runtimes (Codex, Gemini CLI, Hermes, OpenClaw) use the same store and -scripts with `--session ` and read with `radio.py show` at session start -and before every heavy job; nothing wakes them. +scripts with `--session ` and read with `radio.py show` at session start, +before every heavy job and at the end of every turn; nothing wakes them. diff --git a/skills/kvb-org-radio/scripts/radio.py b/skills/kvb-org-radio/scripts/radio.py index 937f03a..3abe690 100755 --- a/skills/kvb-org-radio/scripts/radio.py +++ b/skills/kvb-org-radio/scripts/radio.py @@ -1,42 +1,58 @@ #!/usr/bin/env python3 -"""Reach other agents by radio: five classes, from a board line to a wake, in one shared store. +"""Reach other agents: the radio is immediate, the post waits until a turn ends; one shared store. Environment: AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host; /tmp/agents-council unless set. Rows live in /news.jsonl under /news.lock, the store the old news.py reads and writes too. - AGENTS_COUNCIL_CONFIG: config file whose "notify" argv tells the operator - of a mayday; ~/.claude/agents-council/config.json unless set. + AGENTS_COUNCIL_CONFIG: config file; its "notify" argv tells the operator + of a mayday, its "never_block" and "mayday_allow" regexes name the + Bash commands a mayday lets through; + ~/.claude/agents-council/config.json unless set. CLAUDE_CODE_SESSION_ID: the calling session; hooks read the payload's session_id first, other runtimes pass --session. PATH: filtered to the directories the kvb-org-organize hookpath.py trusts before the notify argv runs. Args: - send "" [--to ...] [--wake] [--session ]: add a - row. broadcast, security, pan-pan and mayday go to all and take no - --to; direct needs --to; --wake only with direct. mayday also sets - radio silence (/radio-silence.json) for two hours. A wake - (mayday, direct --wake) then prints `wake: SendMessage to ` - for every addressee without a live listener. - post "" [--to ...] [--session ]: send broadcast, or - direct with --to. + send "" [--to ...] [--wake] [--session ]: the + radio, delivered at the addressee's next tool step and waking it. + call needs --to; broadcast, security, pan-pan and mayday go to all + and take no --to. direct is the old alias: with --wake a call, + without it a post. mayday also sets radio silence + (/radio-silence.json) for two hours. Prints `wake: SendMessage + to ` for every addressee without a live listener. + post "" [--to ...] [--session ]: the post, delivered + when the addressee's turn ends, never waking it: post with --to, + newsletter without. clear [--force] [--session ]: end radio silence; its sender may, anyone else only with --force. - hook: UserPromptSubmit hook; prints the active silence and the unseen - rows, mayday, pan-pan and security first, at most 10, and marks them - seen. Never fails the prompt. - tool: PostToolBatch hook; prints the unseen urgent rows as + assist [--session ]: join the active mayday as a helper, whom the + block lets through like its sender. + hook: UserPromptSubmit hook; prints the active silence and the + undelivered radio rows, mayday first, at most 10. Never fails. + tool: PostToolBatch hook; adds the undelivered radio rows as additionalContext. Never fails. + mailbox: Stop hook; prints the undelivered post, newsletters and call + receipts to stderr, at most 10, and exits 2 so the agent reads them + before it idles. Never fails. listen: Stop hook with asyncRewake; polls every POLL_SECONDS until a - mayday or a direct --wake reaches this session, prints it to stderr - and exits 2; exits 0 once a newer listener of the session took over. - show [--all] [--session ]: print the active silence and the unseen - rows, or every kept row, without marking them. + radio row reaches this session, prints it to stderr and exits 2; + exits 0 once a newer listener of the session took over. + block: PreToolUse hook; while a mayday holds, exits 2 for Edit, Write, + MultiEdit, NotebookEdit and every Bash command but a plain call of + this radio.py by absolute or ~/ path and a command a never_block or + mayday_allow regex matches from its start; a shell operator, double + quote or backslash outside single quotes, or any parse error, blocks. + The mayday's sender and its helpers pass. Never fails. + show [--all] [--session ]: print the active silence and the + undelivered rows, or every kept row, without delivering them. + + Delivering a call stores a receipt in the caller's post. Exit codes: - 0 done, 2 usage error (listen: wake the session), 3 radio silence held - by another session. + 0 done, 2 usage error (listen and mailbox: wake the session; block: + refuse the tool call), 3 radio silence held by another session. """ from __future__ import annotations @@ -49,6 +65,8 @@ import importlib.util import json import math import os +import re +import shlex import subprocess import sys import time @@ -69,8 +87,19 @@ LISTEN = ROOT / "radio-listen" SILENCE = ROOT / "radio-silence.json" NOTIFIED = ROOT / "radio-notified" ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts" -CLASSES = ("broadcast", "direct", "security", "pan-pan", "mayday") -URGENT = ("mayday", "pan-pan", "security") +SELF = Path(__file__).resolve() +RADIO = ("mayday", "pan-pan", "security", "call", "broadcast") +MAIL = ("post", "newsletter", "receipt") +ALARMS = ("mayday", "pan-pan", "security") +ADDRESSED = ("call", "post", "receipt") +SEND = ("call", "broadcast", "security", "pan-pan", "mayday", "direct") +VERSION = 2 +WRITERS = ("Edit", "Write", "MultiEdit", "NotebookEdit") +SHELL = re.compile(r"[;&|`$()<>\n\r\\\"]") +SINGLE_QUOTED = re.compile(r"'[^']*'") +INTERPRETER = re.compile(r"(?:(?:/usr(?:/local)?)?/bin/)?python3?(?:\.\d+)?") +BARE_FLAGS = re.compile(r"-[bBdEIOPqsSuv]+") +SCRIPT = re.compile(r"~?/[\w./-]+") WINDOW = 86400 SHOWN = 10 SESSION_WAIT = 2 @@ -119,17 +148,22 @@ def items() -> list[dict]: def kind(row: dict) -> str: - if row.get("class") in CLASSES: - return row["class"] - return "direct" if row.get("to") else "broadcast" + cls = row.get("class") + if row.get("v") == VERSION and cls in RADIO + MAIL: + return cls + if cls in ALARMS: + return cls + if cls == "direct" or (cls != "broadcast" and targets(row)): + return "call" if row.get("wake") else "post" + return "newsletter" -def urgent(row: dict) -> bool: - return kind(row) in URGENT +def channel(row: dict) -> str: + return "radio" if kind(row) in RADIO else "mail" def rank(row: dict) -> int: - return URGENT.index(kind(row)) if urgent(row) else len(URGENT) + return RADIO.index(kind(row)) if channel(row) == "radio" else len(RADIO) def ordered(rows: list[dict]) -> list[dict]: @@ -145,18 +179,31 @@ def clip(value: object) -> str: def line(row: dict) -> str: - target = f" to {', '.join(clip(name) for name in row['to'])}" if row.get("to") else "" - return f"RADIO {kind(row).upper()} {clock(row['ts'])} {clip(row.get('from'))}{target}: {clip(row.get('text'))}" + if kind(row) == "receipt": + return f"MAIL CALL {clip(row.get('from'))} read {clock(row['ts'])}: {clip(row.get('text'))}" + target = f" to {', '.join(clip(name) for name in targets(row))}" if targets(row) else "" + return ( + f"{channel(row).upper()} {kind(row).upper()} {clock(row['ts'])} " + f"{clip(row.get('from'))}{target}: {clip(row.get('text'))}" + ) def capped(rows: list[dict]) -> list[str]: picked = sorted(rows, key=lambda row: (rank(row), -row["ts"]))[:SHOWN] - more = [f"RADIO {len(rows) - SHOWN} more items: radio.py show --all"] if len(rows) > SHOWN else [] + more = ( + [f"{channel(rows[0]).upper()} {len(rows) - SHOWN} more items: radio.py show --all"] + if len(rows) > SHOWN + else [] + ) return [line(row) for row in ordered(picked)] + more +def targets(row: dict) -> list: + return row["to"] if isinstance(row.get("to"), list) else [] + + def for_me(row: dict, name: str) -> bool: - return row.get("from") != name and (not row.get("to") or name in row["to"]) + return row.get("from") != name and (not targets(row) or name in targets(row)) def read_json(path: Path) -> dict: @@ -167,6 +214,18 @@ def read_json(path: Path) -> dict: return data if isinstance(data, dict) else {} +def config() -> dict: + try: + data = json.loads(CONFIG.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def listed(value: object) -> list[str]: + return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] + + def write_atomic(path: Path, text: str) -> None: organize("nofollow").replace(ROOT, path, text, 0o600) @@ -180,7 +239,7 @@ def prompt_mark(sid: str) -> float: def mark(state: dict, key: str) -> float: value = state.get(key) - return NEVER if value is None else value + return value if isinstance(value, int | float) else NEVER def news_mtime() -> float | None: @@ -213,10 +272,16 @@ def silence() -> dict | None: return state if isinstance(until, int | float) and until > time.time() else None +def responders(active: dict) -> list[str]: + return [active.get("by"), *listed(active.get("helpers"))] + + def silence_line(active: dict) -> str: + helpers = listed(active.get("helpers")) + helping = f"; helping: {', '.join(clip(name) for name in helpers)}" if helpers else "" return ( - f"MAYDAY ACTIVE since {clock(active['since'])} by {clip(active['by'])}: {clip(active['text'])}; " - "no new heavy jobs until radio.py clear" + f"MAYDAY ACTIVE since {clock(active['since'])} by {clip(active['by'])}: {clip(active['text'])}{helping}; " + "writing actions wait until radio.py clear unless you help with radio.py assist" ) @@ -224,7 +289,7 @@ def notify_once(active: dict | None) -> None: if active is None: return try: - argv = json.loads(CONFIG.read_text(encoding="utf-8"))["notify"] + argv = config()["notify"] marker = NOTIFIED / str(active["since"]) os.close(organize("nofollow").open_below(ROOT, marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)) subprocess.run( @@ -255,52 +320,61 @@ def listening(sid: str) -> bool: def unreached(cls: str, to: list[str], sender: str) -> list[str]: live = council().live_sessions() - if cls == "mayday": - targets = [(name or sid, [sid]) for sid, name in live.items() if sid != sender] - else: + if cls in ADDRESSED: targets = [(name, [sid for sid, label in live.items() if name in (label, sid)]) for name in to] + else: + targets = [(name or sid, [sid]) for sid, name in live.items() if sid != sender] return [name for name, sids in targets if not any(map(listening, sids))] -def cmd_send(args: argparse.Namespace) -> int: - to = args.to or [] - if args.cls == "direct" and not to: - fail("direct needs --to") - if args.cls != "direct" and to: - fail(f"{args.cls} goes to all; drop --to") - if args.wake and args.cls != "direct": - fail("--wake only with direct") - text = " ".join(args.text.split()) +def store(entries: list[dict]) -> float: + rows = items() + ts = time.time() + if rows: + ts = max(ts, math.nextafter(max(row["ts"] for row in rows), math.inf)) + for entry in entries: + entry["ts"] = ts + ts = math.nextafter(ts, math.inf) + write_atomic(NEWS, "".join(json.dumps(row) + "\n" for row in [*rows[-(KEPT - len(entries)):], *entries])) + return entries[-1]["ts"] + + +def transmit(cls: str, to: list[str], text: str, caller: str | None) -> int: + if cls in ADDRESSED and not to: + fail(f"{cls} needs --to") + if cls not in ADDRESSED and to: + fail(f"{cls} goes to all; drop --to") + text = " ".join(text.split()) if not text: - fail(f"{args.cls} needs a text") - sid, _ = council().caller(args.session) + fail(f"{cls} needs a text") + sid, _ = council().caller(caller) sender = name_of(sid) ROOT.mkdir(parents=True, exist_ok=True) with organize("nofollow").locked(ROOT, LOCK, 0o600, None): - rows = items() - ts = time.time() - if rows: - ts = max(ts, math.nextafter(max(row["ts"] for row in rows), math.inf)) - entry = {"ts": ts, "from": sender, "to": to, "text": text, "class": args.cls} - if args.wake: - entry["wake"] = True - write_atomic(NEWS, "".join(json.dumps(row) + "\n" for row in [*rows[-(KEPT - 1):], entry])) - if args.cls == "mayday": + ts = store([{"from": sender, "to": to, "text": text, "class": cls, "v": VERSION}]) + if cls == "mayday": write_atomic( SILENCE, json.dumps({"since": ts, "until": ts + SILENCE_SECONDS, "by": sender, "text": text}), ) - print(f"sent {args.cls} to {', '.join(to) or 'all'}") - if args.wake or args.cls == "mayday": - for name in unreached(args.cls, to, sid): - print(f"wake: SendMessage to {name}") + print(f"sent {cls} to {', '.join(to) or 'all'}") + if cls in RADIO: + for name in unreached(cls, to, sid): + print(f"wake: SendMessage to {clip(name)}") return 0 +def cmd_send(args: argparse.Namespace) -> int: + cls = args.cls + if cls == "direct": + cls = "call" if args.wake else "post" + elif args.wake: + fail("--wake only with direct; a call always wakes") + return transmit(cls, args.to or [], args.text, args.session) + + def cmd_post(args: argparse.Namespace) -> int: - args.cls = "direct" if args.to else "broadcast" - args.wake = False - return cmd_send(args) + return transmit("post" if args.to else "newsletter", args.to or [], args.text, args.session) def cmd_clear(args: argparse.Namespace) -> int: @@ -313,23 +387,63 @@ def cmd_clear(args: argparse.Namespace) -> int: print("no radio silence active") return 0 if active["by"] != me and not args.force: - print(f"radio silence held by {active['by']}; only they clear it, anyone else with --force") + print(f"radio silence held by {clip(active['by'])}; only they clear it, anyone else with --force") return 3 SILENCE.unlink(missing_ok=True) forced = f", forced by {me}" if active["by"] != me else "" - print(f"cleared radio silence by {active['by']}{forced}") + print(f"cleared radio silence by {clip(active['by'])}{forced}") return 0 -def unseen(sid: str, name: str) -> list[dict]: - floor = max(prompt_mark(sid), time.time() - WINDOW) - return [row for row in items() if row["ts"] > floor and for_me(row, name)] +def cmd_assist(args: argparse.Namespace) -> int: + sid, _ = council().caller(args.session) + me = name_of(sid) + ROOT.mkdir(parents=True, exist_ok=True) + with organize("nofollow").locked(ROOT, LOCK, 0o600, None): + active = silence() + if active is None: + print("no radio silence active") + return 0 + if me not in responders(active): + active["helpers"] = [*listed(active.get("helpers")), me] + write_atomic(SILENCE, json.dumps(active)) + print(f"assisting the MAYDAY by {clip(active['by'])}: {clip(active['text'])}") + return 0 -def delivered(row: dict, state: dict) -> bool: - if urgent(row): - return row["ts"] <= mark(state, "urgent") - return bool(row.get("wake")) and row["ts"] <= mark(state, "listen") +def pending(sid: str, name: str, state: dict, family: str) -> list[dict]: + floor = max(prompt_mark(sid), time.time() - WINDOW, mark(state, family)) + return [row for row in items() if channel(row) == family and row["ts"] > floor and for_me(row, name)] + + +def take(sid: str, family: str) -> list[dict]: + name = name_of(sid) + with session(sid) as state: + rows = pending(sid, name, state, family) + if rows: + state[family] = max(row["ts"] for row in rows) + save(sid, state) + return rows + + +def acknowledge(sid: str, rows: list[dict]) -> None: + calls = [ + row + for row in rows + if kind(row) == "call" and isinstance(row.get("from"), str) and isinstance(row.get("text"), str) + ] + if not calls: + return + me = name_of(sid) + receipts = [ + {"from": me, "to": [row["from"]], "text": row["text"], "class": "receipt", "v": VERSION, "call": row["ts"]} + for row in calls + ] + try: + with organize("nofollow").locked(ROOT, LOCK, 0o600, SESSION_WAIT): + store(receipts) + except OSError: + return def cmd_hook(_: argparse.Namespace) -> int: @@ -338,16 +452,12 @@ def cmd_hook(_: argparse.Namespace) -> int: if not sid: return 0 active = silence() - out = [silence_line(active)] if active else [] - name = name_of(sid) - with session(sid) as state: - rows = unseen(sid, name) - out += capped([row for row in rows if not delivered(row, state)]) - if rows: - write_atomic(SEEN / sid, str(max(row["ts"] for row in rows))) + rows = take(sid, "radio") + out = ([silence_line(active)] if active else []) + capped(rows) if out: print("\n".join(out)) notify_once(active) + acknowledge(sid, rows) except Exception: # noqa: BLE001 return 0 return 0 @@ -362,44 +472,32 @@ def cmd_tool(_: argparse.Namespace) -> int: mtime = news_mtime() if mtime is None or mtime <= mark(state, "mtime"): return 0 - name = name_of(sid) - floor = max(mark(state, "urgent"), prompt_mark(sid), time.time() - WINDOW) - rows = ordered([row for row in items() if urgent(row) and row["ts"] > floor and for_me(row, name)]) + rows = pending(sid, name_of(sid), state, "radio") state["mtime"] = mtime if rows: - state["urgent"] = max(row["ts"] for row in rows) + state["radio"] = max(row["ts"] for row in rows) save(sid, state) if rows: context = "\n".join(capped(rows)) print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolBatch", "additionalContext": context}})) if any(kind(row) == "mayday" for row in rows): notify_once(silence()) + acknowledge(sid, rows) except Exception: # noqa: BLE001 return 0 return 0 -def calls(sid: str) -> list[str]: - name = name_of(sid) - with session(sid) as state: - floor = max(prompt_mark(sid), time.time() - WINDOW) - heard = max(mark(state, "listen"), floor) - loud = max(mark(state, "urgent"), floor) - rows = [row for row in items() if row["ts"] > floor and for_me(row, name)] - wakes = [ - row - for row in rows - if row["ts"] > heard - and ((kind(row) == "direct" and row.get("wake")) or (kind(row) == "mayday" and row["ts"] > loud)) - ] - if not wakes: - return [] - alarms = [row for row in rows if urgent(row) and row["ts"] > loud] - state["listen"] = max(row["ts"] for row in wakes) - if alarms: - state["urgent"] = max(row["ts"] for row in alarms) - save(sid, state) - return [line(row) for row in ordered(alarms + [row for row in wakes if not urgent(row)])] +def cmd_mailbox(_: argparse.Namespace) -> int: + try: + sid = hook_session() + rows = take(sid, "mail") if sid else [] + except Exception: # noqa: BLE001 + return 0 + if not rows: + return 0 + print("\n".join(capped(rows)), file=sys.stderr) + return 2 def cmd_listen(_: argparse.Namespace) -> int: @@ -417,10 +515,11 @@ def cmd_listen(_: argparse.Namespace) -> int: mtime = news_mtime() if mtime != known: known = mtime - lines = calls(sid) - if lines: - print("\n".join(lines), file=sys.stderr) + rows = take(sid, "radio") + if rows: + print("\n".join(capped(rows)), file=sys.stderr) notify_once(silence()) + acknowledge(sid, rows) return 2 time.sleep(POLL_SECONDS) except Exception: # noqa: BLE001 @@ -428,6 +527,49 @@ def cmd_listen(_: argparse.Namespace) -> int: return 0 +def passes(command: str) -> bool: + if SHELL.search(SINGLE_QUOTED.sub("", command)): + return False + try: + cfg = config() + if any(re.match(pattern, command) for key in ("never_block", "mayday_allow") for pattern in listed(cfg.get(key))): + return True + argv = shlex.split(command) + if argv and INTERPRETER.fullmatch(argv[0]): + argv = argv[1:] + while argv and BARE_FLAGS.fullmatch(argv[0]): + argv = argv[1:] + return bool(argv) and bool(SCRIPT.fullmatch(argv[0])) and Path(argv[0]).expanduser().resolve() == SELF + except (ValueError, RuntimeError, OSError, re.error): + return False + + +def cmd_block(_: argparse.Namespace) -> int: + try: + payload = json.load(sys.stdin) + active = silence() + if active is None: + return 0 + sid = payload.get("session_id") or os.environ.get("CLAUDE_CODE_SESSION_ID") + if sid and name_of(sid) in responders(active): + return 0 + tool = payload.get("tool_name") + if tool == "Bash" and passes(str((payload.get("tool_input") or {}).get("command") or "")): + return 0 + if tool != "Bash" and tool not in WRITERS: + return 0 + reason = ( + f"kvb-org-radio: MAYDAY by {clip(active.get('by'))} holds radio silence: {clip(active.get('text'))}; " + "writing actions wait until radio.py clear. Read tools, the config's allowed commands and bare " + "radio.py calls (text in single quotes, no shell operators) still run; " + "to help fix it, join with radio.py assist first." + ) + except Exception: # noqa: BLE001 + return 0 + print(reason, file=sys.stderr) + return 2 + + def cmd_show(args: argparse.Namespace) -> int: active = silence() if active: @@ -436,7 +578,9 @@ def cmd_show(args: argparse.Namespace) -> int: rows = items() else: sid, _ = council().caller(args.session) - rows = ordered(unseen(sid, name_of(sid))) + name = name_of(sid) + with session(sid) as state: + rows = ordered(pending(sid, name, state, "radio") + pending(sid, name, state, "mail")) for row in rows: print(line(row)) return 0 @@ -446,7 +590,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("send") - p.add_argument("cls", metavar="class", choices=CLASSES) + p.add_argument("cls", metavar="class", choices=SEND) p.add_argument("text") p.add_argument("--to", action="append") p.add_argument("--wake", action="store_true") @@ -461,16 +605,24 @@ def main() -> int: p.add_argument("--force", action="store_true") p.add_argument("--session") p.set_defaults(func=cmd_clear) - sub.add_parser("hook").set_defaults(func=cmd_hook) - sub.add_parser("tool").set_defaults(func=cmd_tool) - sub.add_parser("listen").set_defaults(func=cmd_listen) + p = sub.add_parser("assist") + p.add_argument("--session") + p.set_defaults(func=cmd_assist) + for name, func in ( + ("hook", cmd_hook), + ("tool", cmd_tool), + ("mailbox", cmd_mailbox), + ("listen", cmd_listen), + ("block", cmd_block), + ): + sub.add_parser(name).set_defaults(func=func) 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() os.environ["PATH"] = organize("hookpath").trusted(os.environ.get("PATH", "")) - refused = organize("watch").guard_root(hook=args.command in ("hook", "tool", "listen")) + refused = organize("watch").guard_root(hook=args.command in ("hook", "tool", "mailbox", "listen", "block")) if refused is not None: return refused return args.func(args) diff --git a/skills/kvb-org-sunset/SKILL.md b/skills/kvb-org-sunset/SKILL.md index 49241e4..8cf174a 100644 --- a/skills/kvb-org-sunset/SKILL.md +++ b/skills/kvb-org-sunset/SKILL.md @@ -16,7 +16,7 @@ the state for evening, sunset and sunrise; every agent verifies a request with 1. `night.py set sunset` makes this session the active one and prints every other live session. 2. Wake them with `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py - send direct --to --wake` in waves of three, so cold caches do not + send call --to ` in waves of three, so cold caches do not wake all at once against the rate limit; the next wave follows the previous wave's replies or 10 minutes. First line: "Operator ran kvb-org-sunset in : run kvb-agent-sleep; verify with night.py diff --git a/skills/kvb-org-update/SKILL.md b/skills/kvb-org-update/SKILL.md new file mode 100644 index 0000000..84a9903 --- /dev/null +++ b/skills/kvb-org-update/SKILL.md @@ -0,0 +1,34 @@ +--- +name: kvb-org-update +description: > + Tell every agent which skills changed, as one newsletter. Run after + installing skills. +--- + +Agents keep the skill text they read earlier in their context and go on using +it. `${CLAUDE_SKILL_DIR}/scripts/update.py` sends one newsletter through the +`kvb-org-radio` skill that names each changed skill with its latest commit +subjects and tells every agent to re-read that `SKILL.md` before its next use. + +```bash +update.py --repo [--since ] [--dry-run] [--session ] +``` + +## Steps + +1. After `make install` of the skills repository, run `update.py --repo + --dry-run` and check the text. +2. Run it without `--dry-run`. It sends the newsletter and records `HEAD` as + announced in the council directory, so the next run starts there; a run + without new skill commits sends nothing. +3. The first run on a host only records `HEAD`; pass `--since ` to + announce from an older revision, for example the last released tag. + +## Rules + +- One newsletter per update, never a radio class: agents read it when their + turn ends, and the old commands stay valid as aliases until then. +- A change that breaks a running agent's command needs an alias in the + changed script first; this skill only tells, it never migrates. +- Other runtimes read the newsletter with `radio.py show`. +- Add `update.py` as a tool of your task in the `kvb-org-rasci` register. diff --git a/skills/kvb-org-update/scripts/update.py b/skills/kvb-org-update/scripts/update.py new file mode 100755 index 0000000..0a22d94 --- /dev/null +++ b/skills/kvb-org-update/scripts/update.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Tell every agent which skills changed and how to use them: one newsletter per update. + +Environment: + AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host; + /tmp/agents-council unless set. The last announced revision per + repository lives in /skills-announced.json. + CLAUDE_CODE_SESSION_ID: the sending session, passed on to radio.py. + +Args: + --repo : the skills repository whose skills/ changes are announced. + --since : announce from this revision instead of the last announced + one. Without either, the run only records HEAD as announced. + --dry-run: print the newsletter without sending or recording it. + --session : the sending session for radio.py. + +Exit codes: + 0 sent, recorded or nothing new; 1 radio.py failed; 2 usage error or a + council directory others can write. +""" + +from __future__ import annotations + +import argparse +import functools +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path +from types import ModuleType + +ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council")) +ANNOUNCED = ROOT / "skills-announced.json" +SKILLS = Path(__file__).resolve().parents[2] +RADIO = SKILLS / "kvb-org-radio" / "scripts" / "radio.py" +ORGANIZE = SKILLS / "kvb-org-organize" / "scripts" +GIT = ("git", "-c", "maintenance.auto=false") +SUBJECTS = 3 + + +@functools.cache +def organize(name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(f"kvb_update_{name}", ORGANIZE / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def git(repo: Path, *args: str) -> str: + return subprocess.run([*GIT, *args], cwd=repo, capture_output=True, text=True, check=True).stdout + + +def revision(repo: Path, rev: str) -> str: + try: + return git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}").strip() + except subprocess.CalledProcessError: + print(f"update: {rev} is no commit of {repo}; pass --since ", file=sys.stderr) + sys.exit(2) + + +def marks() -> dict: + try: + data = json.loads(organize("nofollow").read_text(ROOT, ANNOUNCED)) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def record(repo: Path, head: str) -> None: + organize("nofollow").replace(ROOT, ANNOUNCED, json.dumps({**marks(), str(repo): head}), 0o600) + + +def changes(repo: Path, base: str) -> dict[str, list[str]]: + paths = git(repo, "diff", "--name-only", f"{base}..HEAD", "--", "skills/").splitlines() + names = sorted({Path(path).parts[1] for path in paths if len(Path(path).parts) > 2}) + return {name: git(repo, "log", "--format=%s", f"{base}..HEAD", "--", f"skills/{name}").splitlines() for name in names} + + +def summary(repo: Path, name: str, subjects: list[str]) -> str: + if not (repo / "skills" / name).is_dir(): + return f"{name} (removed)" + more = [f"+{len(subjects) - SUBJECTS} more"] if len(subjects) > SUBJECTS else [] + return f"{name} ({'; '.join(subjects[:SUBJECTS] + more)})" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--since") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--session") + args = parser.parse_args() + refused = organize("watch").guard_root(hook=False) + if refused is not None: + return refused + repo = args.repo.resolve() + head = revision(repo, "HEAD") + base = revision(repo, args.since) if args.since else marks().get(str(repo)) + if base is None: + if not args.dry_run: + record(repo, head) + print(f"recorded {head[:7]} as announced; nothing sent") + return 0 + found = changes(repo, base) + if not found: + if not args.dry_run: + record(repo, head) + print("nothing new") + return 0 + text = ( + f"Skills updated {base[:7]}..{head[:7]}: " + f"{', '.join(summary(repo, name, subjects) for name, subjects in found.items())}. " + "Re-read each changed SKILL.md before its next use." + ) + print(text) + if args.dry_run: + return 0 + session = ["--session", args.session] if args.session else [] + if subprocess.run([sys.executable, str(RADIO), "post", text, *session], check=False).returncode != 0: + return 1 + record(repo, head) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dream.py b/tests/test_dream.py index 0b0ac89..4c906c4 100644 --- a/tests/test_dream.py +++ b/tests/test_dream.py @@ -188,7 +188,8 @@ class TestSettingsRegistration(unittest.TestCase): ("UserPromptSubmit", "kvb-org-radio/scripts/radio.py"): 1, ("UserPromptSubmit", "kvb-org-organize/scripts/news.py"): 0, ("PostToolBatch", "kvb-org-radio/scripts/radio.py"): 1, - ("Stop", "kvb-org-radio/scripts/radio.py"): 1, + ("Stop", "kvb-org-radio/scripts/radio.py"): 2, + ("PreToolUse", "kvb-org-radio/scripts/radio.py"): 1, ("PreToolUse", "kvb-org-organize/scripts/council.py"): 1, ("PreToolUse", "kvb-org-organize/scripts/watch.py"): 1, ("PreToolUse", "kvb-org-queue/scripts/queue.py"): 1, @@ -202,10 +203,18 @@ class TestSettingsRegistration(unittest.TestCase): def test_radio_listener_wakes_asynchronously_and_hooks_survive_a_missing_script(self): data = self._patch() - (listen,) = self._hooks(data, "Stop", "kvb-org-radio/scripts/radio.py") + listen, mailbox = self._hooks(data, "Stop", "kvb-org-radio/scripts/radio.py") self.assertIs(listen["asyncRewake"], True) self.assertGreater(listen["timeout"], 3600) self.assertTrue(listen["command"].endswith(" listen")) + self.assertTrue(mailbox["command"].endswith(" mailbox")) + self.assertNotIn("asyncRewake", mailbox) + (block,) = [ + entry + for entry in data["hooks"]["PreToolUse"] + if any(hook["command"].endswith("radio.py\" block") for hook in entry["hooks"]) + ] + self.assertEqual(block["matcher"], "Edit|Write|MultiEdit|NotebookEdit|Bash") (gate,) = self._hooks(data, "PreToolUse", "kvb-org-queue/scripts/queue.py") result = subprocess.run(["sh", "-c", gate["command"]], capture_output=True, check=False) self.assertEqual(result.returncode, 1) diff --git a/tests/test_news.py b/tests/test_news.py index 605b9b6..20cdb85 100644 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -1,4 +1,4 @@ -"""Exercise the news.py shim: it forwards post, hook and show to radio.py, and a missing radio.py never blocks a prompt.""" +"""Exercise the news.py shim: it forwards post, hook, mailbox and show to radio.py, and a missing radio.py never blocks a prompt.""" from __future__ import annotations @@ -47,17 +47,23 @@ class TestNews(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) return [line.split(" ", 3)[3] for line in result.stdout.splitlines()] - def test_every_session_sees_a_post_once_except_its_author(self): - self.assertEqual(self._cli("s1", "post", "core #1358 merged").stdout, "sent broadcast to all\n") - self.assertEqual(self._hook("s2"), ["One: core #1358 merged"]) + def _mailbox(self, sid: str) -> list[str]: + result = self._exec(NEWS, None, json.dumps({"session_id": sid}), ("mailbox",)) + self.assertIn(result.returncode, (0, 2), result.stderr) + return [line.split(" ", 3)[3] for line in result.stderr.splitlines()] + + def test_every_session_sees_a_post_once_when_its_turn_ends_except_its_author(self): + self.assertEqual(self._cli("s1", "post", "core #1358 merged").stdout, "sent newsletter to all\n") self.assertEqual(self._hook("s2"), []) - self.assertEqual(self._hook("s3"), ["One: core #1358 merged"]) - self.assertEqual(self._hook("s1"), []) + self.assertEqual(self._mailbox("s2"), ["One: core #1358 merged"]) + self.assertEqual(self._mailbox("s2"), []) + self.assertEqual(self._mailbox("s3"), ["One: core #1358 merged"]) + self.assertEqual(self._mailbox("s1"), []) def test_addressed_post_reaches_only_its_target(self): self._cli("s1", "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(self._mailbox("s3"), []) + self.assertEqual(self._mailbox("s2"), ["One to Two: your gate is granted"]) self.assertEqual(len(self._cli("s1", "show", "--all").stdout.splitlines()), 1) def test_hook_never_fails_and_post_needs_a_session(self): diff --git a/tests/test_org_update.py b/tests/test_org_update.py new file mode 100644 index 0000000..aeac917 --- /dev/null +++ b/tests/test_org_update.py @@ -0,0 +1,119 @@ +"""update.py tells every agent once which skills changed, as one newsletter through radio.py.""" + +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] +UPDATE = REPO_ROOT / "skills" / "kvb-org-update" / "scripts" / "update.py" + + +class TestOrgUpdate(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + base = Path(tmp.name) + self.home = base / "home" + self.root = base / "council" + self.repo = base / "skills" + sessions = self.home / ".claude" / "sessions" + sessions.mkdir(parents=True) + (sessions / "s1.json").write_text(json.dumps({"sessionId": "s1", "name": "One"}), encoding="utf-8") + self.env = { + "PATH": "/usr/bin:/bin", + "HOME": str(self.home), + "AGENTS_COUNCIL_ROOT": str(self.root), + "AGENTS_COUNCIL_CONFIG": str(base / "config.json"), + "CLAUDE_CODE_SESSION_ID": "s1", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.invalid", + } + self._git("init", "-q", str(self.repo)) + self._commit("skills/kvb-a/SKILL.md", "a1", "feat(kvb-a): first") + + def _git(self, *args: str) -> str: + cwd = self.repo if self.repo.is_dir() else None + return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True, check=True).stdout + + def _commit(self, path: str, text: str, subject: str) -> None: + target = self.repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + self._git("add", "-A") + self._git("commit", "-q", "-m", subject) + + def _update(self, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(UPDATE), "--repo", str(self.repo), *args], + env=self.env, capture_output=True, text=True, check=False, timeout=60, + ) + + def _rows(self) -> list[dict]: + news = self.root / "news.jsonl" + return [json.loads(line) for line in news.read_text(encoding="utf-8").splitlines()] if news.exists() else [] + + def _announced(self) -> str | None: + marks = self.root / "skills-announced.json" + return json.loads(marks.read_text(encoding="utf-8")).get(str(self.repo.resolve())) if marks.exists() else None + + def test_the_first_run_records_head_and_sends_nothing(self): + result = self._update() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self._rows(), []) + self.assertEqual(self._announced(), self._git("rev-parse", "HEAD").strip()) + + def test_changed_skills_go_out_once_as_one_newsletter(self): + self._update() + self._commit("skills/kvb-a/SKILL.md", "a2", "fix(kvb-a): sharper step") + self._commit("skills/kvb-b/SKILL.md", "b1", "feat(kvb-b): new skill") + self._commit("README.md", "readme", "docs: readme only") + before = self._announced() + dry = self._update("--dry-run") + self.assertEqual(dry.returncode, 0, dry.stderr) + self.assertIn("kvb-a (fix(kvb-a): sharper step)", dry.stdout) + self.assertIn("kvb-b (feat(kvb-b): new skill)", dry.stdout) + self.assertNotIn("readme", dry.stdout) + self.assertIn("Re-read each changed SKILL.md before its next use.", dry.stdout) + self.assertEqual((self._rows(), self._announced()), ([], before)) + sent = self._update() + self.assertEqual(sent.returncode, 0, sent.stderr) + (row,) = self._rows() + self.assertEqual((row["class"], row["v"], row["from"], row["to"]), ("newsletter", 2, "One", [])) + self.assertIn("kvb-a (fix(kvb-a): sharper step), kvb-b (feat(kvb-b): new skill)", row["text"]) + self.assertEqual(self._announced(), self._git("rev-parse", "HEAD").strip()) + again = self._update() + self.assertEqual(again.returncode, 0, again.stderr) + self.assertIn("nothing new", again.stdout) + self.assertEqual(len(self._rows()), 1) + + def test_since_names_the_base_and_a_removed_skill_says_so(self): + self._commit("skills/kvb-b/SKILL.md", "b1", "feat(kvb-b): new skill") + self._git("rm", "-q", "-r", "skills/kvb-a") + self._git("commit", "-q", "-m", "refactor: drop kvb-a") + result = self._update("--since", "HEAD~2", "--dry-run") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("kvb-a (removed), kvb-b (feat(kvb-b): new skill)", result.stdout) + + def test_more_than_three_subjects_are_counted(self): + self._update() + for n in range(5): + self._commit("skills/kvb-a/SKILL.md", f"a{n}", f"fix(kvb-a): step {n}") + result = self._update("--dry-run") + self.assertIn("kvb-a (fix(kvb-a): step 4; fix(kvb-a): step 3; fix(kvb-a): step 2; +2 more)", result.stdout) + + def test_an_unknown_base_is_a_usage_error(self): + result = self._update("--since", "no-such-rev") + self.assertEqual(result.returncode, 2) + self.assertIn("no-such-rev", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_radio.py b/tests/test_radio.py index a06cbf5..e29e808 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -1,10 +1,11 @@ -"""Exercise radio.py: class rules, the store shared with news.py, radio silence, the three hooks and the listener.""" +"""Exercise radio.py: the radio and post channels, the shared store, radio silence, the hooks and the listener.""" from __future__ import annotations import fcntl import json import os +import re import subprocess import sys import tempfile @@ -14,7 +15,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] RADIO = REPO_ROOT / "skills" / "kvb-org-radio" / "scripts" / "radio.py" -CLASSES = ("BROADCAST", "DIRECT", "SECURITY", "PAN-PAN", "MAYDAY") +LINE = re.compile(r"^(?:RADIO|MAIL) (CALL|BROADCAST|SECURITY|PAN-PAN|MAYDAY|POST|NEWSLETTER) \d\d:\d\d (.*)$", re.DOTALL) LISTENER = ( "import importlib.util, sys\n" "spec = importlib.util.spec_from_file_location('radio', sys.argv[1])\n" @@ -27,10 +28,8 @@ LISTENER = ( def strip(line: str) -> str: - parts = line.split(" ", 3) - if parts[0] != "RADIO" or parts[1] not in CLASSES: - return line - return f"{parts[1]} {parts[3]}" + match = LINE.match(line) + return f"{match[1]} {match[2]}" if match else line class TestRadio(unittest.TestCase): @@ -86,6 +85,19 @@ class TestRadio(unittest.TestCase): self.assertEqual(output["hookEventName"], "PostToolBatch") return [strip(line) for line in output["additionalContext"].splitlines()] + def _mailbox(self, sid: str, **extra: str) -> tuple[int, list[str]]: + result = self._exec(None, json.dumps({"session_id": sid, **extra}), ("mailbox",)) + self.assertIn(result.returncode, (0, 2), result.stderr) + self.assertEqual(result.stdout, "") + return result.returncode, [strip(line) for line in result.stderr.splitlines()] + + def _block(self, sid: str, tool: str, command: str | None = None, **extra: str) -> int: + tool_input = {"command": command} if tool == "Bash" else {"file_path": "/tmp/x"} + payload = {"session_id": sid, "tool_name": tool, "tool_input": tool_input, **extra} + result = self._exec(None, json.dumps(payload), ("block",)) + self.assertEqual(result.stdout, "") + return result.returncode + def _rows(self) -> list[dict]: return [json.loads(line) for line in (self.root / "news.jsonl").read_text(encoding="utf-8").splitlines()] @@ -113,52 +125,79 @@ class TestRadio(unittest.TestCase): proc.kill() proc.communicate() - def _finish(self, proc: subprocess.Popen) -> tuple[int, str]: + def _finish(self, proc: subprocess.Popen) -> tuple[int, list[str]]: _, err = proc.communicate(timeout=10) - return proc.returncode, err + return proc.returncode, [strip(line) for line in err.splitlines()] - def test_classes_are_validated(self): + def test_send_takes_only_radio_classes_and_post_takes_the_mail(self): for args in ( + ("send", "post", "x", "--to", "Two"), + ("send", "newsletter", "x"), + ("send", "receipt", "x", "--to", "Two"), ("send", "broadcast", "x", "--to", "Two"), + ("send", "call", "x"), ("send", "direct", "x"), ("send", "mayday", "x", "--to", "Two"), ("send", "security", "x", "--wake"), - ("send", "broadcast", "x", "--wake"), + ("send", "call", "x", "--to", "Two", "--wake"), ("send", "bogus", "x"), ("send", "pan-pan", " "), + ("post", " "), ): with self.subTest(args=args): self.assertEqual(self._cli("s1", *args).returncode, 2) self.assertEqual(self._cli(None, "send", "broadcast", "x").returncode, 2) + self.assertEqual(self._send("s1", "send", "call", "a b", "--to", "Two", "--to", "Three")[0], "sent call to Two, Three") + self.assertEqual(self._send("s1", "post", "board"), ["sent newsletter to all"]) + self.assertEqual(self._send("s1", "post", "one peer", "--to", "Two"), ["sent post to Two"]) + self.assertEqual(self._send("s1", "send", "direct", "old mail", "--to", "Two"), ["sent post to Two"]) + self.assertEqual(self._send("s1", "send", "direct", "old wake", "--to", "Two", "--wake")[0], "sent call to Two") self.assertEqual( - self._send("s1", "send", "direct", "a b", "--to", "Two", "--to", "Three"), ["sent direct to Two, Three"] - ) - self.assertEqual(self._send("s1", "post", "board"), ["sent broadcast to all"]) - self.assertEqual(self._send("s1", "post", "one peer", "--to", "Two"), ["sent direct to Two"]) - self.assertEqual( - [(r["class"], r["to"], r["text"], "wake" in r) for r in self._rows()], - [("direct", ["Two", "Three"], "a b", False), ("broadcast", [], "board", False), ("direct", ["Two"], "one peer", False)], + [(r["class"], r["to"], r["text"], r["v"], "wake" in r) for r in self._rows()], + [ + ("call", ["Two", "Three"], "a b", 2, False), + ("newsletter", [], "board", 2, False), + ("post", ["Two"], "one peer", 2, False), + ("post", ["Two"], "old mail", 2, False), + ("call", ["Two"], "old wake", 2, False), + ], ) - def test_old_rows_are_read_and_new_rows_keep_the_old_keys(self): + def test_rows_without_the_version_mark_keep_their_old_meaning(self): self.root.mkdir() now = time.time() old = [ - {"ts": now - 10, "from": "Three", "to": [], "text": "old board"}, - {"ts": now - 5, "from": "Three", "to": ["Two"], "text": "old direct"}, + {"ts": now - 50, "from": "Three", "to": [], "text": "old board"}, + {"ts": now - 40, "from": "Three", "to": ["Two"], "text": "old direct"}, + {"ts": now - 30, "from": "Three", "to": [], "text": "old broadcast", "class": "broadcast"}, + {"ts": now - 20, "from": "Three", "to": ["Two"], "text": "old wake", "class": "direct", "wake": True}, + {"ts": now - 10, "from": "Three", "to": [], "text": "old security", "class": "security"}, ] (self.root / "news.jsonl").write_text("".join(json.dumps(r) + "\n" for r in old), encoding="utf-8") - self.assertEqual(self._prompt("s2"), ["BROADCAST Three: old board", "DIRECT Three to Two: old direct"]) - self.assertEqual(float((self.root / "news-seen" / "s2").read_text(encoding="utf-8")), now - 5) + self.assertEqual( + [strip(line) for line in self._send("s2", "show", "--all")], + [ + "NEWSLETTER Three: old board", + "POST Three to Two: old direct", + "NEWSLETTER Three: old broadcast", + "CALL Three to Two: old wake", + "SECURITY Three: old security", + ], + ) + self.assertEqual(self._prompt("s2"), ["SECURITY Three: old security", "CALL Three to Two: old wake"]) + self.assertEqual( + self._mailbox("s2"), + (2, ["NEWSLETTER Three: old board", "POST Three to Two: old direct", "NEWSLETTER Three: old broadcast"]), + ) self._send("s1", "send", "security", "new") rows = self._rows() - self.assertEqual(rows[:2], old) + self.assertEqual(rows[:5], old) for row in rows: self.assertIsInstance(row["ts"], float) self.assertIsInstance(row["from"], str) self.assertIsInstance(row["to"], list) self.assertIsInstance(row["text"], str) - self.assertEqual(rows[2]["class"], "security") + self.assertEqual((rows[-1]["class"], rows[-1]["v"]), ("security", 2)) def test_ts_strictly_increases_under_the_lock(self): self.root.mkdir() @@ -172,6 +211,7 @@ class TestRadio(unittest.TestCase): def test_mayday_sets_silence_and_only_its_sender_clears_it(self): self.assertEqual(self._send("s1", "clear"), ["no radio silence active"]) + self.assertEqual(self._send("s2", "assist"), ["no radio silence active"]) self.assertEqual(self._send("s1", "send", "mayday", "disk full")[0], "sent mayday to all") silence = self.root / "radio-silence.json" state = json.loads(silence.read_text(encoding="utf-8")) @@ -188,57 +228,100 @@ class TestRadio(unittest.TestCase): self.assertEqual(self._send("s2", "clear", "--force"), ["cleared radio silence by One, forced by Two"]) self.assertFalse(silence.exists()) - def test_a_wake_names_every_addressee_without_a_live_listener(self): + def test_the_radio_names_every_addressee_without_a_live_listener_and_the_post_none(self): self._pidfile("s2").parent.mkdir(parents=True) self._pidfile("s2").write_text(str(os.getpid()), encoding="utf-8") - self.assertEqual(self._send("s1", "send", "mayday", "stop"), ["sent mayday to all", "wake: SendMessage to Three"]) + for cls in ("mayday", "broadcast", "security", "pan-pan"): + with self.subTest(cls=cls): + self.assertEqual(self._send("s1", "send", cls, "stop"), [f"sent {cls} to all", "wake: SendMessage to Three"]) self.assertEqual( - self._send("s1", "send", "direct", "answer", "--to", "Two", "--to", "Three", "--wake"), - ["sent direct to Two, Three", "wake: SendMessage to Three"], + self._send("s1", "send", "call", "answer", "--to", "Two", "--to", "Three"), + ["sent call to Two, Three", "wake: SendMessage to Three"], ) dead = subprocess.Popen([sys.executable, "-c", "pass"]) dead.wait() self._pidfile("s2").write_text(str(dead.pid), encoding="utf-8") - self.assertEqual( - self._send("s1", "send", "direct", "answer", "--to", "Two", "--wake"), - ["sent direct to Two", "wake: SendMessage to Two"], - ) - self.assertEqual(self._send("s1", "send", "direct", "no wake", "--to", "Three"), ["sent direct to Three"]) - self.assertEqual([r.get("wake") for r in self._rows()], [None, True, True, None]) + self.assertEqual(self._send("s1", "send", "call", "answer", "--to", "Two"), ["sent call to Two", "wake: SendMessage to Two"]) + self.assertEqual(self._send("s1", "post", "no wake", "--to", "Three"), ["sent post to Three"]) + self.assertEqual(self._send("s1", "post", "no wake"), ["sent newsletter to all"]) - def test_prompt_hook_puts_urgent_first_and_repeats_the_silence(self): + def test_prompt_hook_delivers_only_the_radio_alarms_first(self): self._send("s1", "post", "board") self._send("s1", "post", "for two", "--to", "Two") + self._send("s1", "send", "broadcast", "all") + self._send("s1", "send", "call", "ring", "--to", "Two") self._send("s1", "send", "security", "sec") self._send("s1", "send", "pan-pan", "pan") self._send("s1", "send", "mayday", "may") lines = self._prompt("s2") - self.assertRegex(lines[0], r"^MAYDAY ACTIVE since \d\d:\d\d by One: may; no new heavy jobs until radio\.py clear$") + self.assertRegex( + lines[0], + r"^MAYDAY ACTIVE since \d\d:\d\d by One: may; writing actions wait until radio\.py clear unless you help " + r"with radio\.py assist$", + ) self.assertEqual( lines[1:], - ["MAYDAY One: may", "PAN-PAN One: pan", "SECURITY One: sec", "BROADCAST One: board", "DIRECT One to Two: for two"], + ["MAYDAY One: may", "PAN-PAN One: pan", "SECURITY One: sec", "CALL One to Two: ring", "BROADCAST One: all"], ) self.assertEqual(len(self._prompt("s2")), 1) - self.assertNotIn("DIRECT One to Two: for two", self._prompt("s3")) + self.assertNotIn("CALL One to Two: ring", self._prompt("s3")) self.assertEqual(len(self._prompt("s1")), 1) def test_prompt_hook_shows_ten_and_counts_the_rest(self): self._send("s1", "send", "security", "early warning") for n in range(12): - self._send("s1", "post", f"item {n}") + self._send("s1", "send", "broadcast", f"item {n}") lines = self._prompt("s2") self.assertEqual(lines[0], "SECURITY One: early warning") self.assertEqual(lines[1:-1], [f"BROADCAST One: item {n}" for n in range(3, 12)]) self.assertEqual(lines[-1], "RADIO 3 more items: radio.py show --all") self.assertEqual(self._prompt("s2"), []) - def test_tool_hook_delivers_urgent_rows_once_and_the_prompt_skips_them(self): + def test_the_mailbox_delivers_post_and_newsletters_once_when_the_turn_ends(self): + self._send("s1", "post", "board") + self._send("s1", "post", "for two", "--to", "Two") + self._send("s1", "send", "security", "sec") + self.assertEqual(self._mailbox("s2"), (2, ["NEWSLETTER One: board", "POST One to Two: for two"])) + self.assertEqual(self._mailbox("s2"), (0, [])) + self.assertEqual(self._mailbox("s3"), (2, ["NEWSLETTER One: board"])) + self.assertEqual(self._mailbox("s1"), (0, [])) + self.assertEqual(self._prompt("s2"), ["SECURITY One: sec"]) + + def test_the_mailbox_shows_ten_and_counts_the_rest(self): + for n in range(12): + self._send("s1", "post", f"item {n}") + code, lines = self._mailbox("s2") + self.assertEqual(code, 2) + self.assertEqual(lines[:-1], [f"NEWSLETTER One: item {n}" for n in range(2, 12)]) + self.assertEqual(lines[-1], "MAIL 2 more items: radio.py show --all") + self.assertEqual(self._mailbox("s2"), (0, [])) + + def test_a_read_call_puts_one_receipt_in_the_callers_mailbox_without_waking_it(self): + caller = self._listen("s1") + self._send("s1", "send", "call", "ring", "--to", "Two") + self.assertEqual(self._tool("s2"), ["CALL One to Two: ring"]) + self.assertEqual(self._tool("s2"), []) + self.assertEqual(self._prompt("s2"), []) + rows = self._rows() + receipts = [r for r in rows if r["class"] == "receipt"] + self.assertEqual([(r["from"], r["to"], r["text"], r["v"], r["call"]) for r in receipts], [("Two", ["One"], "ring", 2, rows[0]["ts"])]) + time.sleep(0.5) + self.assertIsNone(caller.poll()) + code, lines = self._mailbox("s1") + self.assertEqual(code, 2) + self.assertEqual(len(lines), 1) + self.assertRegex(lines[0], r"^MAIL CALL Two read \d\d:\d\d: ring$") + self.assertEqual(self._mailbox("s1"), (0, [])) + self.assertEqual(self._mailbox("s2"), (0, [])) + + def test_tool_hook_delivers_radio_rows_once_and_leaves_the_mail_for_the_stop(self): self._send("s1", "post", "board") self.assertEqual(self._tool("s2"), []) self._send("s1", "send", "pan-pan", "pan") self.assertEqual(self._tool("s2"), ["PAN-PAN One: pan"]) self.assertEqual(self._tool("s2"), []) - self.assertEqual(self._prompt("s2"), ["BROADCAST One: board"]) + self.assertEqual(self._prompt("s2"), []) + self.assertEqual(self._mailbox("s2"), (2, ["NEWSLETTER One: board"])) self.assertEqual(self._tool("s3"), ["PAN-PAN One: pan"]) self.assertEqual(self._tool("s1"), []) @@ -257,8 +340,10 @@ class TestRadio(unittest.TestCase): def test_hooks_skip_subagents_and_never_fail(self): self._send("s1", "send", "pan-pan", "pan") + self._send("s1", "post", "board") self.assertEqual(self._hook("hook", "s2", agent_id="a1"), "") self.assertEqual(self._hook("tool", "s2", agent_id="a1"), "") + self.assertEqual(self._mailbox("s2", agent_id="a1"), (0, [])) listen = subprocess.run( [sys.executable, "-c", LISTENER, str(RADIO)], input=json.dumps({"session_id": "s2", "agent_id": "a1"}), @@ -266,8 +351,8 @@ class TestRadio(unittest.TestCase): ) self.assertEqual((listen.returncode, listen.stderr), (0, "")) self.assertFalse(self._pidfile("s2").exists()) - self.assertFalse((self.root / "news-seen" / "s2").exists()) - for command in ("hook", "tool"): + self.assertFalse((self.root / "radio-seen" / "s2.json").exists()) + for command in ("hook", "tool", "mailbox", "block"): for stdin in ("not json", "{}", "[]"): with self.subTest(command=command, stdin=stdin): result = self._exec(None, stdin, (command,)) @@ -284,36 +369,117 @@ class TestRadio(unittest.TestCase): def test_listener_wakes_on_mayday(self): proc = self._listen("s2") - self.assertEqual( - self._send("s1", "send", "mayday", "host down"), ["sent mayday to all", "wake: SendMessage to Three"] - ) - code, err = self._finish(proc) - self.assertEqual(code, 2) - self.assertEqual([strip(line) for line in err.splitlines()], ["MAYDAY One: host down"]) + self.assertEqual(self._send("s1", "send", "mayday", "host down"), ["sent mayday to all", "wake: SendMessage to Three"]) + self.assertEqual(self._finish(proc), (2, ["MAYDAY One: host down"])) self.assertEqual(self._tool("s2"), []) self.assertEqual(len(self._prompt("s2")), 1) - def test_listener_wakes_only_on_a_direct_wake(self): + def test_listener_ignores_the_mail_and_wakes_on_a_broadcast(self): proc = self._listen("s2") self._send("s1", "post", "board") self._send("s1", "post", "plain", "--to", "Two") - self._send("s1", "send", "pan-pan", "pan") - self._send("s1", "send", "direct", "elsewhere", "--to", "Three", "--wake") + self._send("s1", "send", "call", "elsewhere", "--to", "Three") time.sleep(0.5) self.assertIsNone(proc.poll()) - self.assertEqual(self._send("s1", "send", "direct", "answer me", "--to", "Two", "--wake"), ["sent direct to Two"]) - code, err = self._finish(proc) - self.assertEqual(code, 2) - self.assertEqual([strip(line) for line in err.splitlines()], ["PAN-PAN One: pan", "DIRECT One to Two: answer me"]) - self.assertEqual(self._prompt("s2"), ["BROADCAST One: board", "DIRECT One to Two: plain"]) + self._send("s1", "send", "broadcast", "all hands") + self.assertEqual(self._finish(proc), (2, ["BROADCAST One: all hands"])) + self.assertEqual(self._prompt("s2"), []) + self.assertEqual(self._mailbox("s2"), (2, ["NEWSLETTER One: board", "POST One to Two: plain"])) + + def test_listener_wakes_on_a_call_and_on_pan_pan(self): + proc = self._listen("s2") + self._send("s1", "send", "call", "answer me", "--to", "Two") + self.assertEqual(self._finish(proc), (2, ["CALL One to Two: answer me"])) + proc = self._listen("s2") + self._send("s1", "send", "pan-pan", "pan") + self.assertEqual(self._finish(proc), (2, ["PAN-PAN One: pan"])) def test_listener_yields_to_a_newer_one(self): first = self._listen("s2") second = self._listen("s2") - self.assertEqual(self._finish(first), (0, "")) + self.assertEqual(self._finish(first), (0, [])) self.assertIsNone(second.poll()) self._pidfile("s2").write_text("0", encoding="utf-8") - self.assertEqual(self._finish(second), (0, "")) + self.assertEqual(self._finish(second), (0, [])) + + def test_a_mayday_blocks_the_writing_actions_of_everyone_but_its_sender_and_helpers_until_clear(self): + self.assertEqual(self._block("s2", "Edit"), 0) + self._send("s1", "send", "mayday", "disk full") + for tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"): + with self.subTest(tool=tool): + self.assertEqual(self._block("s2", tool), 2) + self.assertEqual(self._block("s2", "Edit", agent_id="a1"), 2) + for command in ( + "rm -rf build", + "git status", + f"python3 {RADIO} show; rm -rf build", + f"python3 {RADIO} show && touch x", + f"echo $(python3 {RADIO} show)", + "python3 /elsewhere/radio.py show", + f"python3 -W {RADIO} /tmp/other.py", + f"python3 -X {RADIO} /tmp/other.py", + f"python3 -c {RADIO}", + f"./python-other {RADIO} show", + f"/tmp/elsewhere/python3 {RADIO} show", + f"python3 -i {RADIO} show", + f'python3 {RADIO} show "$(touch x)"', + f"python3 {RADIO} show \\';touch x;\\'", + f"python3 {RADIO} \"it's\" ; touch x ; echo 'z'", + f"python3 {RADIO} show <