feat(kvb-org): add the night cycle to run one agent under token limits

kvb-org-evening and kvb-org-sunset narrow the organisation to the session the operator types them in; kvb-org-sunrise undoes it. night.py keeps the state in <council root>/night.json: set makes the caller active and lists the other live sessions, status tells each session its part (active, awake, relax, sleep), wake lifts one session alone, clear ends the night; a missing or invalid state means day. Evening messages every other session to run kvb-agent-relax (finish the task, end its loops, start nothing); sunset messages them in waves of three to run kvb-agent-sleep (park at the next safe point, never cut a running job short, end loops, write a ten-line handover into the project's memory directory, reply asleep), and the operator then sets inbound messages to hold. At night the active session admits its own runs through watch.py and leaves the sleepers' offices alone. Sunrise switches messages back to accept, clears the state and sends kvb-agent-wakeup, which restores loops, jobs and roles from the handover; the operator can also wake one session alone by typing it there.

A peer's request to do less now needs no operator approval in the kvb-org-organize Peer protocol, and kvb-org-robot stops a run at its next safe point when the night reaches it. The README gains the agent category's state meaning and the new nodes; test_night covers set, status, wake, clear and a bad state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-04 17:07:22 +01:00
1 parent 4ffbc85411
commit 22e91b2877
11 files changed
+446 -3

No files matched your search

+20 -1
View File
@@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline
## Naming 🏷️
Every first-party skill is named `kvb-<category>-<name>` 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-<category>-<name>` 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
+16
View File
@@ -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: <what still runs>`. Waking up is the
`kvb-agent-wakeup` skill.
+36
View File
@@ -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_<session id>.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: <handover path>`, 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.
+29
View File
@@ -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_<session id>.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: <what resumes>`.
+22
View File
@@ -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 <name>: 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 |
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+29
View File
@@ -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 <session id>` 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 |
+41
View File
@@ -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 <name>: 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
<slot>` (`${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 |
+154
View File
@@ -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 <root>/night.json. /tmp/agents-council unless set.
CLAUDE_CODE_SESSION_ID: the calling session.
Args:
set evening|sunset [--session <id>]: start the mode with the caller as the
active session; prints every other live session as "<id>\t<name>".
status [--session <id>]: print the mode, the active session, since when,
and the caller's part (active, awake, relax or sleep).
wake [--session <id>]: wake the caller alone while the others stay in the
mode; it then works like the active session.
clear [--session <id>]: end the mode (sunrise); prints every other live
session as "<id>\t<name>".
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())
+91
View File
@@ -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()