From 22e91b2877a1e7edbbe9d2321eb54899e2bf3d4e Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 21:15:31 +0100 Subject: [PATCH] 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 /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 --- README.md | 21 +++- skills/kvb-agent-relax/SKILL.md | 16 +++ skills/kvb-agent-sleep/SKILL.md | 36 ++++++ skills/kvb-agent-wakeup/SKILL.md | 29 +++++ skills/kvb-org-evening/SKILL.md | 22 ++++ skills/kvb-org-organize/SKILL.md | 5 +- skills/kvb-org-robot/SKILL.md | 5 +- skills/kvb-org-sunrise/SKILL.md | 29 +++++ skills/kvb-org-sunset/SKILL.md | 41 +++++++ skills/kvb-org-sunset/scripts/night.py | 154 +++++++++++++++++++++++++ tests/test_night.py | 91 +++++++++++++++ 11 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 skills/kvb-agent-relax/SKILL.md create mode 100644 skills/kvb-agent-sleep/SKILL.md create mode 100644 skills/kvb-agent-wakeup/SKILL.md create mode 100644 skills/kvb-org-evening/SKILL.md create mode 100644 skills/kvb-org-sunrise/SKILL.md create mode 100644 skills/kvb-org-sunset/SKILL.md create mode 100755 skills/kvb-org-sunset/scripts/night.py create mode 100644 tests/test_night.py 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()