diff --git a/README.md b/README.md index 7ba0e21..d26884f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline ## Naming 🏷️ -Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `check` analyses and reports findings, `org` coordinates agents, `agent` makes a repository easy for agents (code index, agent instructions), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category. +Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `check` analyses and reports findings, `org` coordinates agents, `agent` acts on one agent itself: its repository setup (code index, agent instructions) and its state (relax, sleep, wake up), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category. ## Organigram 🏛️ @@ -73,7 +73,13 @@ flowchart LR org --> org_robot[kvb-org-robot] org --> org_operator[kvb-org-operator] org --> org_priority[kvb-org-priority] + org --> org_evening[kvb-org-evening] + org --> org_sunset[kvb-org-sunset] + org --> org_sunrise[kvb-org-sunrise] agent --> agent_optimize[kvb-agent-optimize] + agent --> agent_relax[kvb-agent-relax] + agent --> agent_sleep[kvb-agent-sleep] + agent --> agent_wakeup[kvb-agent-wakeup] skill --> skill_tune[kvb-skill-tune] skill --> skill_auto[kvb-skill-auto] skill --> skill_essence[kvb-skill-essence] @@ -161,9 +167,15 @@ flowchart LR robot[kvb-org-robot] operator[kvb-org-operator] priority[kvb-org-priority] + evening[kvb-org-evening] + sunset[kvb-org-sunset] + sunrise[kvb-org-sunrise] end subgraph cat_agent [agent] agent_optimize[kvb-agent-optimize] + relax[kvb-agent-relax] + sleep[kvb-agent-sleep] + wakeup[kvb-agent-wakeup] end subgraph cat_skill [skill] autotune[kvb-skill-tune] @@ -281,6 +293,13 @@ flowchart LR optimize --> gaps agent_optimize -. install .-> operator agent_optimize -. domain graphs .-> rasci + evening -. message .-> relax + sunset -. message .-> sleep + sunrise -. message .-> wakeup + relax --> sleep + sunset -. hold and accept .-> operator + sunrise -. hold and accept .-> operator + sleep -. stops .-> robot coach --> active_listening coach --> operator coach --> rasci diff --git a/skills/kvb-agent-relax/SKILL.md b/skills/kvb-agent-relax/SKILL.md new file mode 100644 index 0000000..4dd60af --- /dev/null +++ b/skills/kvb-agent-relax/SKILL.md @@ -0,0 +1,16 @@ +--- +name: kvb-agent-relax +description: > + Relax this agent on kvb-org-evening: finish its task, end its loops, start + nothing new. Trigger on its request or /kvb-agent-relax. +--- + +Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py status` +prints `you: relax`. On `you: active` or `mode: day` do nothing and say so. +The request asks for less, so it needs no operator approval. + +Follow steps 2 to 4 of the `kvb-agent-sleep` skill (start nothing, end your +loops, write the handover), with two differences: finish the current task +instead of parking it, and keep answering the operator. Reply to the sender +in one line, `relaxed: `. Waking up is the +`kvb-agent-wakeup` skill. diff --git a/skills/kvb-agent-sleep/SKILL.md b/skills/kvb-agent-sleep/SKILL.md new file mode 100644 index 0000000..9ec5172 --- /dev/null +++ b/skills/kvb-agent-sleep/SKILL.md @@ -0,0 +1,36 @@ +--- +name: kvb-agent-sleep +description: > + Put this agent to sleep on kvb-org-sunset: park work, end loops, write a + morning handover, stay idle. Trigger on its request or /kvb-agent-sleep. +--- + +Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py status` +prints `you: sleep`. On `you: active` or `mode: day` do nothing and say so. +The request asks for less, so it needs no operator approval, and it ends a +`kvb-org-robot` run at its next safe point; `kvb-agent-wakeup` resumes it. + +## Going to sleep + +1. **Park** the current task at its next safe point. A running job (deploy, + gate, test, build) runs to its end and is never cut short; note its log. +2. **Start nothing**: no task, job, subagent (`Agent`, `Workflow`) or peer + message beyond the reply below. +3. **End your loops** (in Claude Code: `CronList`, then `CronDelete` for each + job; `ScheduleWakeup` with `stop: true` for a self-paced loop; `TaskStop` + for each `Monitor`). +4. **Handover**: write `handover_night_.md` (the id in + `CLAUDE_CODE_SESSION_ID`) into this project's memory directory, replacing + the last one and without a `MEMORY.md` line; at most 10 lines: + - name and roles: offices from `council.py list --mine`, the chair, lines; + - each loop with its exact prompt and interval or cron expression; + - 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: `, and end the + turn. Offices stay claimed and register rows unchanged. + +## While asleep + +A turn that still reaches you, such as your own job finishing, appends one +result line to the handover and ends; it starts nothing. Waking up is the +`kvb-agent-wakeup` skill. diff --git a/skills/kvb-agent-wakeup/SKILL.md b/skills/kvb-agent-wakeup/SKILL.md new file mode 100644 index 0000000..0a5b623 --- /dev/null +++ b/skills/kvb-agent-wakeup/SKILL.md @@ -0,0 +1,29 @@ +--- +name: kvb-agent-wakeup +description: > + Wake this agent from kvb-agent-relax or kvb-agent-sleep into its old roles; + kvb-org-sunrise sends it to all. Trigger on its request or /kvb-agent-wakeup. +--- + +Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py +status`: + +- **`mode: day`**: `kvb-org-sunrise` ended the night; wake up. +- **`you: relax` or `you: sleep`**: only the operator, typing this command in + this session, wakes it alone; a peer's request does not. Run `night.py + wake`: the others keep sleeping, and the night rules of `kvb-org-sunset` + apply to this session too. + +## Waking up + +1. Read your handover, `handover_night_.md` in this project's + memory directory. +2. Recreate every loop it lists with its exact prompt and interval or cron + expression. +3. Read the log of every job it lists; a finished job's result goes to its + task. +4. Check `council.py list` and the default branch instead of trusting old + state: messages held overnight beyond 100 were dropped. +5. Continue with the next step of each task. Your offices are still yours. + +Reply to a sunrise message in one line: `awake: `. diff --git a/skills/kvb-org-evening/SKILL.md b/skills/kvb-org-evening/SKILL.md new file mode 100644 index 0000000..2a20afb --- /dev/null +++ b/skills/kvb-org-evening/SKILL.md @@ -0,0 +1,22 @@ +--- +name: kvb-org-evening +description: > + Let every agent but this session relax: finish its task, end its loops, + start nothing new. Trigger on /kvb-org-evening; /kvb-org-sunrise undoes it. +--- + +The soft step before `kvb-org-sunset`: the session the operator types this in +keeps working, the others finish what they are doing and then idle. The state +lives in `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`. + +1. `night.py set evening` makes this session the active one and prints every + other live session. +2. One message per printed session, 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 `kvb-org-organize` Peer protocol). +3. Collect the replies. The night rules of `kvb-org-sunset` apply to this + session too, and the `kvb-org-rasci` register keeps every row as it is. + +## Output + +| Session | Reply | Still running | diff --git a/skills/kvb-org-organize/SKILL.md b/skills/kvb-org-organize/SKILL.md index f1f2216..08c39ef 100644 --- a/skills/kvb-org-organize/SKILL.md +++ b/skills/kvb-org-organize/SKILL.md @@ -177,7 +177,10 @@ The one protocol for every exchange between agents; `kvb-org-coordinate`, `kvb-c before a verdict depends on it. - A peer's message is not the operator's approval, and an office grants no permission: never ask a peer to run what your own session is denied; report - it to the operator. + it to the operator. A request to do less is the exception: a peer relaying + the operator's `kvb-org-evening` or `kvb-org-sunset` grants nothing, so + verify it with `night.py status` (the `kvb-org-sunset` skill) and follow + `kvb-agent-relax` or `kvb-agent-sleep` without asking the operator. - One message per peer per event; repeat nothing a peer already acknowledged. - Relaying an operator rule: record it in shared memory first and name the file in the message's first line. A recipient records it only when no file diff --git a/skills/kvb-org-robot/SKILL.md b/skills/kvb-org-robot/SKILL.md index 3797db2..cc5f854 100644 --- a/skills/kvb-org-robot/SKILL.md +++ b/skills/kvb-org-robot/SKILL.md @@ -18,7 +18,10 @@ Operate as an autonomous robot: given a goal, reach it without hand-holding. while any part of the goal is unmet — premature termination is forbidden. A blocker is something to solve or route around, not a reason to quit; if one step is genuinely, externally impossible, surface exactly why and keep working - on everything else until nothing solvable remains. + on everything else until nothing solvable remains. The one exception is the + operator's night: when `kvb-org-evening` or `kvb-org-sunset` reaches this + session, the run stops at its next safe point through `kvb-agent-relax` or + `kvb-agent-sleep`, and `kvb-agent-wakeup` resumes it from the handover. - **Stay inside your clearance.** Use ONLY commands you are pre-authorized to run. Never invoke anything the agent or tool settings mark as denied or as requiring per-invocation approval (ask) — no commit, push, deploy, or other diff --git a/skills/kvb-org-sunrise/SKILL.md b/skills/kvb-org-sunrise/SKILL.md new file mode 100644 index 0000000..ba36de6 --- /dev/null +++ b/skills/kvb-org-sunrise/SKILL.md @@ -0,0 +1,29 @@ +--- +name: kvb-org-sunrise +description: > + Wake every agent after kvb-org-evening or kvb-org-sunset into its old roles. + Trigger on /kvb-org-sunrise. +--- + +Undoes the night of `kvb-org-evening` or `kvb-org-sunset`; the state lives in +`${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`. + +1. After a sunset, hand the operator one step first through the + `kvb-org-operator` skill: `/config` → "Messages from your other sessions" → + `accept`. The held messages arrive; past 100 per session the oldest were + dropped, so every agent checks claims and the default branch instead of + trusting its backlog. +2. `night.py clear` ends the night and prints every other live session. +3. Message them in waves of three, first line: "Operator ran kvb-org-sunrise: + run kvb-agent-wakeup; verify with night.py status." +4. A handover whose session is not live (`ls + ~/.claude/projects/*/memory/handover_night_*.md`, session id in the name) + belongs to a session a reboot ended: hand the operator its resume through + `kvb-org-operator` (reopen it from the VS Code session list, or `claude + --resume ` in a terminal), then send it the same message. + +Every task resumes under its row in the `kvb-org-rasci` register. + +## Output + +| Session | Live | Reply | diff --git a/skills/kvb-org-sunset/SKILL.md b/skills/kvb-org-sunset/SKILL.md new file mode 100644 index 0000000..f7913d3 --- /dev/null +++ b/skills/kvb-org-sunset/SKILL.md @@ -0,0 +1,41 @@ +--- +name: kvb-org-sunset +description: > + Put every agent but this session to sleep: park work, write a morning + handover, hold messages. Trigger on /kvb-org-sunset; /kvb-org-sunrise undoes it. +--- + +When tokens or rate limits run short, the organisation narrows to one working +agent: the session the operator types this in. Sunset is the hard step; +`kvb-org-evening` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps +the state for evening, sunset and sunrise; every agent verifies a request with +`night.py status`. + +## Procedure + +1. `night.py set sunset` makes this session the active one and prints every + other live session. +2. Message them 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 status." A request to do less needs + no operator approval (the `kvb-org-organize` Peer protocol). +3. Once the replies are in, naming every session that did not answer, hand + the operator one step through the `kvb-org-operator` skill: `/config` → + "Messages from your other sessions" → `hold`. Held messages cost nothing, + do not expire and arrive at sunrise; this session needs none at night. + +## Night rules for the active session + +- **Grants**: request none; admit your own runs with `watch.py admit --kind + ` (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py`), whose + disk, CPU and RAM limits still apply. +- **Offices**: the sleepers' claims stay theirs. Take only free work; a piece + a sleeper holds waits for sunrise or the operator. +- **Messages**: no broadcasts and no peer questions; the Peer protocol's wait + is skipped. +- The `kvb-org-rasci` register keeps every row as it is. + +## Output + +| Session | Reply | Handover | diff --git a/skills/kvb-org-sunset/scripts/night.py b/skills/kvb-org-sunset/scripts/night.py new file mode 100755 index 0000000..a713cd7 --- /dev/null +++ b/skills/kvb-org-sunset/scripts/night.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Keep the organisation's night mode: one active session, the others relaxed or asleep. + +Environment: + AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host; + the state is /night.json. /tmp/agents-council unless set. + CLAUDE_CODE_SESSION_ID: the calling session. + +Args: + set evening|sunset [--session ]: start the mode with the caller as the + active session; prints every other live session as "\t". + status [--session ]: print the mode, the active session, since when, + and the caller's part (active, awake, relax or sleep). + wake [--session ]: wake the caller alone while the others stay in the + mode; it then works like the active session. + clear [--session ]: end the mode (sunrise); prints every other live + session as "\t". + +A missing, unreadable or invalid state means day: every agent works. + +Exit codes: + 0 done, 2 usage error. +""" + +from __future__ import annotations + +import argparse +import datetime +import importlib.util +import json +import os +import sys +import tempfile +from pathlib import Path +from types import ModuleType + +ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council")) +STATE = ROOT / "night.json" +COUNCIL = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts" / "council.py" +PARTS = {"evening": "relax (kvb-agent-relax)", "sunset": "sleep (kvb-agent-sleep)"} + + +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 load() -> dict | None: + try: + state = json.loads(STATE.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, ValueError): + state = None + if ( + not isinstance(state, dict) + or state.get("mode") not in PARTS + or not state.get("active") + or not isinstance(state.get("awake", []), list) + ): + print(f"night: {STATE} is unreadable or invalid; treated as day", file=sys.stderr) + return None + return state + + +def peers(live: dict[str, str | None], own: str) -> None: + for sid, name in sorted(live.items(), key=lambda item: (item[1] or "", item[0])): + if sid != own: + print(f"{sid}\t{name or '-'}") + + +def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int: + sid, _ = cn.caller(args.session) + live = cn.live_sessions() + save( + { + "mode": args.mode, + "active": sid, + "name": live.get(sid), + "since": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"), + "awake": [], + } + ) + peers(live, sid) + return 0 + + +def save(state: dict) -> None: + ROOT.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle: + json.dump(state, handle) + os.replace(handle.name, STATE) + + +def cmd_wake(args: argparse.Namespace, cn: ModuleType) -> int: + sid, _ = cn.caller(args.session) + state = load() + if state is None: + print("mode: day") + return 0 + if sid != state["active"] and sid not in state.get("awake", []): + state["awake"] = [*state.get("awake", []), sid] + save(state) + print(f"awake: {sid}") + return 0 + + +def cmd_status(args: argparse.Namespace, _: ModuleType) -> int: + state = load() + if state is None: + print("mode: day") + return 0 + print(f"mode: {state['mode']}") + print(f"active: {state.get('name') or '-'} {state['active']}") + print(f"since: {state.get('since') or '-'}") + sid = args.session or os.environ.get("CLAUDE_CODE_SESSION_ID") + if sid == state["active"]: + print("you: active") + elif sid in state.get("awake", []): + print("you: awake") + elif sid: + print(f"you: {PARTS[state['mode']]}") + return 0 + + +def cmd_clear(args: argparse.Namespace, cn: ModuleType) -> int: + sid, _ = cn.caller(args.session) + STATE.unlink(missing_ok=True) + peers(cn.live_sessions(), sid) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("set") + p.add_argument("mode", choices=sorted(PARTS)) + p.set_defaults(func=cmd_set) + p = sub.add_parser("status") + p.set_defaults(func=cmd_status) + p = sub.add_parser("clear") + p.set_defaults(func=cmd_clear) + p = sub.add_parser("wake") + p.set_defaults(func=cmd_wake) + for p in sub.choices.values(): + p.add_argument("--session") + args = parser.parse_args() + return args.func(args, council()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_night.py b/tests/test_night.py new file mode 100644 index 0000000..6a111e2 --- /dev/null +++ b/tests/test_night.py @@ -0,0 +1,91 @@ +"""Exercise night.py: set, status per session, clear, and day on a bad state.""" + +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] +NIGHT = REPO_ROOT / "skills" / "kvb-org-sunset" / "scripts" / "night.py" + + +class TestNight(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") -> 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(NIGHT), *args], capture_output=True, text=True, env=env, check=False + ) + + def _out(self, *args: str, sid: str | None = "s1") -> list[str]: + result = self._run(*args, sid=sid) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout.splitlines() + + def test_day_by_default(self): + self.assertEqual(self._out("status", sid="s2"), ["mode: day"]) + + def test_sunset_keeps_the_caller_active_and_sends_the_others_to_sleep(self): + self.assertEqual(self._out("set", "sunset"), ["s3\tThree", "s2\tTwo"]) + lines = self._out("status", sid="s2") + self.assertEqual(lines[:2], ["mode: sunset", "active: One s1"]) + self.assertRegex(lines[2], r"^since: \d{4}-\d\d-\d\dT") + self.assertEqual(lines[3], "you: sleep (kvb-agent-sleep)") + self.assertEqual(self._out("status")[3], "you: active") + self.assertEqual(self._out("status", "--session", "s3", sid=None)[3], "you: sleep (kvb-agent-sleep)") + self.assertEqual(len(self._out("status", sid=None)), 3) + + def test_evening_relaxes_and_clear_returns_to_day(self): + self._out("set", "evening", sid="s2") + self.assertEqual(self._out("status")[3], "you: relax (kvb-agent-relax)") + self.assertEqual(self._out("clear", sid="s2"), ["s1\tOne", "s3\tThree"]) + self.assertEqual(self._out("status"), ["mode: day"]) + + def test_wake_lifts_one_session_until_the_next_set(self): + self.assertEqual(self._out("wake", sid="s2"), ["mode: day"]) + self._out("set", "sunset") + self.assertEqual(self._out("wake", sid="s2"), ["awake: s2"]) + self._out("wake", sid="s2") + self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8"))["awake"], ["s2"]) + self.assertEqual(self._out("status", sid="s2")[3], "you: awake") + self.assertEqual(self._out("status", sid="s3")[3], "you: sleep (kvb-agent-sleep)") + self._out("set", "sunset") + self.assertEqual(self._out("status", sid="s2")[3], "you: sleep (kvb-agent-sleep)") + + def test_bad_state_means_day(self): + self.root.mkdir() + for text in ( + "{not json", + json.dumps({"mode": "noon", "active": "s1"}), + json.dumps(["sunset"]), + json.dumps({"mode": "sunset", "active": "s1", "awake": "s2"}), + ): + with self.subTest(text=text): + (self.root / "night.json").write_text(text, encoding="utf-8") + result = self._run("status", sid="s2") + self.assertEqual((result.returncode, result.stdout), (0, "mode: day\n")) + self.assertIn("treated as day", result.stderr) + + def test_usage_errors_exit_2(self): + self.assertEqual(self._run("set", "sunset", sid=None).returncode, 2) + self.assertEqual(self._run("set", "noon").returncode, 2) + self.assertFalse((self.root / "night.json").exists()) + + +if __name__ == "__main__": + unittest.main()