diff --git a/README.md b/README.md index 386fa32..623e55f 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` 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. +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 (drain, suspend, resume), `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 🏛️ @@ -81,17 +81,17 @@ 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] + org --> org_drain[kvb-org-drain] + org --> org_suspend[kvb-org-suspend] + org --> org_resume[kvb-org-resume] 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] - agent --> agent_wakeup[kvb-agent-wakeup] + agent --> agent_drain[kvb-agent-drain] + agent --> agent_suspend[kvb-agent-suspend] + agent --> agent_resume[kvb-agent-resume] skill --> skill_tune[kvb-skill-tune] skill --> skill_auto[kvb-skill-auto] skill --> skill_essence[kvb-skill-essence] @@ -189,18 +189,18 @@ 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] + org_drain[kvb-org-drain] + org_suspend[kvb-org-suspend] + org_resume[kvb-org-resume] radio[kvb-org-radio] queue[kvb-org-queue] org_update[kvb-org-update] end subgraph cat_agent [agent] agent_optimize[kvb-agent-optimize] - relax[kvb-agent-relax] - sleep[kvb-agent-sleep] - wakeup[kvb-agent-wakeup] + agent_drain[kvb-agent-drain] + agent_suspend[kvb-agent-suspend] + agent_resume[kvb-agent-resume] end subgraph cat_skill [skill] autotune[kvb-skill-tune] @@ -345,13 +345,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 + org_drain -. message .-> agent_drain + org_suspend -. message .-> agent_suspend + org_resume -. message .-> agent_resume + agent_drain --> agent_suspend + org_suspend -. hold and accept .-> operator + org_resume -. hold and accept .-> operator + agent_suspend -. stops .-> robot coach --> active_listening coach --> operator coach --> rasci diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 7aac0f5..ce9308e 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -610,11 +610,11 @@ flowchart TD ### Phase 6: night commands (S4) -- [ ] `kvb-org-drain`, `kvb-org-suspend`, `kvb-org-resume`, +- [x] `kvb-org-drain`, `kvb-org-suspend`, `kvb-org-resume`, `kvb-agent-drain`, `kvb-agent-suspend` and `kvb-agent-resume` exist; the old six names exist nowhere in the tree except in the migration and `kvb-skill-cleanup` history (grep test). -- [ ] The Controls menu shows Drain, Suspend and Resume and each needs the +- [x] The Controls menu shows Drain, Suspend and Resume and each needs the passkey, like the board actions today. ### Phase 6: Tasks with asks and choices (S5) diff --git a/orchestrator/kvb_orchestrator/council.py b/orchestrator/kvb_orchestrator/council.py index 1de6f13..9293520 100644 --- a/orchestrator/kvb_orchestrator/council.py +++ b/orchestrator/kvb_orchestrator/council.py @@ -14,7 +14,8 @@ CLASSES = ("post", "call", "newsletter", "broadcast", "mayday", "pan-pan", "secu RECEIPT = "receipt" ALARMS = ("mayday", "pan-pan", "security") VERSION = 2 -MODES = ("evening", "sunset") +MODES = ("drain", "suspend") +RENAMED_MODES = {"evening": "drain", "sunset": "suspend"} RADIO_LIMIT = 5000 GRANT_LIMIT = 50 MAYDAY_ACTIVE = 7200 @@ -154,8 +155,17 @@ def grants(rows: list[dict], window: tuple[float, float], claims: list[dict], ta def night_mode(council: Path) -> str: + """Args: + council: the council directory; night.json in it may still hold a + mode from before the rename, read as its successor. + + Returns: + One of MODES, or day. + """ data = read_json(council / "night.json") mode = data.get("mode") if isinstance(data, dict) else None + if isinstance(mode, str): + mode = RENAMED_MODES.get(mode, mode) return mode if mode in MODES else "day" diff --git a/orchestrator/kvb_orchestrator/messaging.py b/orchestrator/kvb_orchestrator/messaging.py index c1aaeee..865478b 100644 --- a/orchestrator/kvb_orchestrator/messaging.py +++ b/orchestrator/kvb_orchestrator/messaging.py @@ -13,9 +13,9 @@ from .signed import message_signed, signing from .skill_scripts import NIGHT, RADIO, RASCI NIGHTS = { - "evening": (["set", "evening"], "Operator ran kvb-org-evening in the Agent Orchestrator: run kvb-agent-relax; verify with night.py status."), - "sunset": (["set", "sunset"], "Operator ran kvb-org-sunset in the Agent Orchestrator: run kvb-agent-sleep; verify with night.py status."), - "sunrise": (["clear"], "Operator ran kvb-org-sunrise: run kvb-agent-wakeup; verify with night.py status."), + "drain": (["drain"], "Operator ran kvb-org-drain in the Agent Orchestrator: run kvb-agent-drain; verify with night.py status."), + "suspend": (["suspend"], "Operator ran kvb-org-suspend in the Agent Orchestrator: run kvb-agent-suspend; verify with night.py status."), + "resume": (["resume"], "Operator ran kvb-org-resume: run kvb-agent-resume; verify with night.py status."), } UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") DIRECT = {"post": ["post"], "call": ["send", "call"]} diff --git a/orchestrator/kvb_orchestrator/skill_scripts.py b/orchestrator/kvb_orchestrator/skill_scripts.py index 9e0c0e7..2eb9c46 100644 --- a/orchestrator/kvb_orchestrator/skill_scripts.py +++ b/orchestrator/kvb_orchestrator/skill_scripts.py @@ -11,7 +11,7 @@ OUTPUT_LIMIT = 4000 SAFE_PATH = "/usr/local/bin:/usr/bin:/bin" RADIO = "kvb-org-radio/scripts/radio.py" RASCI = "kvb-org-rasci/scripts/rasci.py" -NIGHT = "kvb-org-sunset/scripts/night.py" +NIGHT = "kvb-org-suspend/scripts/night.py" OPERATOR_SCRIPT = "kvb-org-operator/scripts/operator.py" diff --git a/orchestrator/server.py b/orchestrator/server.py index dfc4483..7030b87 100755 --- a/orchestrator/server.py +++ b/orchestrator/server.py @@ -18,7 +18,7 @@ --host-state: the state directory's path on the host, named in the radio rows that carry an upload or a voice recording. --skills: installed skills directory holding the kvb-org-radio, - kvb-org-rasci and kvb-org-sunset scripts. + kvb-org-rasci and kvb-org-suspend scripts. --transcripts: directory the Claude Code transcripts (*.jsonl) lie under. --templates: directory holding index.html.j2 and the *.js modules it includes. diff --git a/orchestrator/templates/page-board.js b/orchestrator/templates/page-board.js index 30377de..487619f 100644 --- a/orchestrator/templates/page-board.js +++ b/orchestrator/templates/page-board.js @@ -65,7 +65,7 @@ const clear = iconBtn("bi-life-preserver", "Cancel mayday", "btn small danger", () => confirmAct("clear-mayday")); const mode = h("span", {}); const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode, - h("p", {class: "muted"}, "Evening: agents finish their task. Sunset: they sleep. Sunrise: all wake up."), + h("p", {class: "muted"}, "Drain: agents finish their task and take nothing new. Suspend: they park their work and idle. Resume: all continue from the handover. Your passkey signs each."), h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id)))))); return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, toAll(), clear, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night), clear, mode}; } diff --git a/orchestrator/templates/page-constants.js b/orchestrator/templates/page-constants.js index 0d40e06..117aab7 100644 --- a/orchestrator/templates/page-constants.js +++ b/orchestrator/templates/page-constants.js @@ -9,8 +9,8 @@ const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"}; const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"}; const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Decision", operator: "Operator", rule: "Rule request"}; - const NIGHT = {day: ["Day", "ok"], evening: ["Evening", "warn"], sunset: ["Night", "violet"]}; - const ACTIONS = [["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?"], ["evening", "Evening", "Send all agents into the evening (kvb-agent-relax)?"], ["sunset", "Sunset", "Put all agents to sleep (kvb-agent-sleep)?"], ["sunrise", "Sunrise", "Wake all agents (kvb-agent-wakeup)?"]]; + const NIGHT = {day: ["Day", "ok"], drain: ["Draining", "warn"], suspend: ["Suspended", "violet"]}; + const ACTIONS = [["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?"], ["drain", "Drain", "Drain all agents: finish the current task, accept nothing new (kvb-agent-drain)?"], ["suspend", "Suspend", "Suspend all agents: park work, write the handover, idle (kvb-agent-suspend)?"], ["resume", "Resume", "Resume all agents from their handover (kvb-agent-resume)?"]]; const UPLOAD_LIMIT = 50 * 1024 * 1024; const STREAM_KEEP = 5000; const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sécurité", receipt: "Read", grant: "Grant"}; diff --git a/skills/kvb-agent-drain/SKILL.md b/skills/kvb-agent-drain/SKILL.md new file mode 100644 index 0000000..bd18610 --- /dev/null +++ b/skills/kvb-agent-drain/SKILL.md @@ -0,0 +1,17 @@ +--- +name: kvb-agent-drain +description: > + Drain this agent on kvb-org-drain: finish its task, end its loops, start + nothing new. +--- + +Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status` +prints `you: drain`. 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-suspend` 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 through the +radio, not a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py +post --to "drained: "`. Resuming is the +`kvb-agent-resume` skill. diff --git a/skills/kvb-agent-relax/SKILL.md b/skills/kvb-agent-relax/SKILL.md deleted file mode 100644 index cdb0b56..0000000 --- a/skills/kvb-agent-relax/SKILL.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: kvb-agent-relax -description: > - Relax this agent on kvb-org-evening: finish its task, end its loops, start - nothing new. ---- - -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 through the -radio, not a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py -post --to "relaxed: "`. Waking up is the -`kvb-agent-wakeup` skill. diff --git a/skills/kvb-agent-wakeup/SKILL.md b/skills/kvb-agent-resume/SKILL.md similarity index 50% rename from skills/kvb-agent-wakeup/SKILL.md rename to skills/kvb-agent-resume/SKILL.md index 8a96238..a028fde 100644 --- a/skills/kvb-agent-wakeup/SKILL.md +++ b/skills/kvb-agent-resume/SKILL.md @@ -1,20 +1,20 @@ --- -name: kvb-agent-wakeup +name: kvb-agent-resume description: > - Wake this agent from kvb-agent-relax or kvb-agent-sleep into its old roles; - kvb-org-sunrise sends it to all. + Resume this agent from kvb-agent-drain or kvb-agent-suspend into its old + roles; kvb-org-resume sends it to all. --- -Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py +Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/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` +- **`mode: day`**: `kvb-org-resume` ended the night; resume. +- **`you: drain` or `you: suspend`**: only the operator, typing this command in + this session, resumes it alone; a peer's request does not. Run `night.py + resume --alone`: the others stay in the mode, and the night rules of `kvb-org-suspend` apply to this session too. -## Waking up +## Resuming 1. Read your handover, `handover_night_.md` in this project's memory directory. @@ -26,6 +26,6 @@ status`: 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 through the radio, not a wake: +Reply to a resume message through the radio, not a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post --to -"awake: "`. +"resumed: "`. diff --git a/skills/kvb-agent-sleep/SKILL.md b/skills/kvb-agent-suspend/SKILL.md similarity index 70% rename from skills/kvb-agent-sleep/SKILL.md rename to skills/kvb-agent-suspend/SKILL.md index ec53710..401112e 100644 --- a/skills/kvb-agent-sleep/SKILL.md +++ b/skills/kvb-agent-suspend/SKILL.md @@ -1,16 +1,16 @@ --- -name: kvb-agent-sleep +name: kvb-agent-suspend description: > - Put this agent to sleep on kvb-org-sunset: park work, end loops, write a - morning handover, stay idle. + Suspend this agent on kvb-org-suspend: park work, end loops, write a + handover, stay idle. --- -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. +Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status` +prints `you: suspend`. 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. +`kvb-org-robot` run at its next safe point; `kvb-agent-resume` resumes it. -## Going to sleep +## Suspending 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. @@ -26,13 +26,13 @@ The request asks for less, so it needs no operator approval, and it ends a - 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: `, by +5. **Reply** to the sender in one line, `suspended: `, by `${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 +## While suspended 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. +result line to the handover and ends; it starts nothing. Resuming is the +`kvb-agent-resume` skill. diff --git a/skills/kvb-org-evening/SKILL.md b/skills/kvb-org-drain/SKILL.md similarity index 52% rename from skills/kvb-org-evening/SKILL.md rename to skills/kvb-org-drain/SKILL.md index 64c1f0d..1be372b 100644 --- a/skills/kvb-org-evening/SKILL.md +++ b/skills/kvb-org-drain/SKILL.md @@ -1,24 +1,24 @@ --- -name: kvb-org-evening +name: kvb-org-drain description: > - Let every agent but this session relax: finish its task, end its loops, - start nothing new. kvb-org-sunrise undoes it. + Drain every agent but this session: finish its task, end its loops, + start nothing new. kvb-org-resume undoes it. --- -The soft step before `kvb-org-sunset`: this session keeps working, every +The soft step before `kvb-org-suspend`: this session keeps working, every other finishes what it is doing and then idles. The state lives in -`${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`. +`${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py`. -1. `night.py set evening` makes this session the active one and prints every +1. `night.py drain` makes this session the active one and prints every other live session. 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 + "Operator ran kvb-org-drain in : run kvb-agent-drain; verify with night.py status." A request to do less needs no operator approval (the `kvb-org-organize` Peer protocol). 3. Collect the one-line replies, which arrive by radio; never ask a silent - session again. The night rules of `kvb-org-sunset` apply to this session + session again. The night rules of `kvb-org-suspend` apply to this session too, and the `kvb-org-rasci` register keeps every row as it is. ## Output diff --git a/skills/kvb-org-organize/relay.md b/skills/kvb-org-organize/relay.md index e620caf..5de8879 100644 --- a/skills/kvb-org-organize/relay.md +++ b/skills/kvb-org-organize/relay.md @@ -4,8 +4,8 @@ file in the message's first line. A recipient records it only when no file is named, and only after `council.py claim memory/`, because peers that read the same broadcast write within seconds of each other. -- A peer relaying the operator's `kvb-org-evening` or `kvb-org-sunset` is the +- A peer relaying the operator's `kvb-org-drain` or `kvb-org-suspend` is the one request a peer may make that you follow without the operator: it asks you to do less and grants nothing. Verify it with `night.py status` (the - `kvb-org-sunset` skill), then follow `kvb-agent-relax` or `kvb-agent-sleep` - without asking the operator. + `kvb-org-suspend` skill), then follow `kvb-agent-drain` or + `kvb-agent-suspend` without asking the operator. diff --git a/skills/kvb-org-sunrise/SKILL.md b/skills/kvb-org-resume/SKILL.md similarity index 64% rename from skills/kvb-org-sunrise/SKILL.md rename to skills/kvb-org-resume/SKILL.md index 9d12227..525aba0 100644 --- a/skills/kvb-org-sunrise/SKILL.md +++ b/skills/kvb-org-resume/SKILL.md @@ -1,20 +1,20 @@ --- -name: kvb-org-sunrise +name: kvb-org-resume description: > - Wake every agent after kvb-org-evening or kvb-org-sunset into its old roles. + Resume every agent after kvb-org-drain or kvb-org-suspend into its old roles. --- -Undoes the night of `kvb-org-evening` or `kvb-org-sunset`; the state lives in -`${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`. +Undoes the night of `kvb-org-drain` or `kvb-org-suspend`; the state lives in +`${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py`. -1. After a sunset, hand the operator one step first through the +1. After a suspend, 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." +2. `night.py resume` ends the night and prints every other live session. +3. Message them in waves of three, first line: "Operator ran kvb-org-resume: + run kvb-agent-resume; 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 diff --git a/skills/kvb-org-robot/SKILL.md b/skills/kvb-org-robot/SKILL.md index 8b5e88e..db207a3 100644 --- a/skills/kvb-org-robot/SKILL.md +++ b/skills/kvb-org-robot/SKILL.md @@ -18,9 +18,9 @@ Operate as an autonomous robot: given a goal, reach it without hand-holding. 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. 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. + operator's night: when `kvb-org-drain` or `kvb-org-suspend` reaches this + session, the run stops at its next safe point through `kvb-agent-drain` or + `kvb-agent-suspend`, and `kvb-agent-resume` 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-sunset/SKILL.md b/skills/kvb-org-suspend/SKILL.md similarity index 58% rename from skills/kvb-org-sunset/SKILL.md rename to skills/kvb-org-suspend/SKILL.md index 8cf174a..4d05d81 100644 --- a/skills/kvb-org-sunset/SKILL.md +++ b/skills/kvb-org-suspend/SKILL.md @@ -1,42 +1,42 @@ --- -name: kvb-org-sunset +name: kvb-org-suspend description: > - Put every agent but this session to sleep: park work, write a morning - handover, hold messages. kvb-org-sunrise undoes it. + Suspend every agent but this session: park work, write a handover, + hold messages. kvb-org-resume 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 +agent: the session the operator types this in. Suspend is the hard step; +`kvb-org-drain` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps +the state for drain, suspend and resume; every agent verifies a request with `night.py status`. ## Procedure -1. `night.py set sunset` makes this session the active one and prints every +1. `night.py suspend` 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 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 + kvb-org-suspend in : run kvb-agent-suspend; 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. + do not expire and arrive at the resume; this session needs none meanwhile. ## 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. +- **Offices**: the suspended agents' claims stay theirs. Take only free work; + a piece a suspended agent holds waits for the resume or the operator. - **Messages**: no wakes and no peer questions; the Peer protocol's wait is - skipped. Status goes to `radio.py post`; sleepers read it at sunrise - without a wake. + skipped. Status goes to `radio.py post`; suspended agents read it at the + resume without a wake. - The `kvb-org-rasci` register keeps every row as it is. ## Output diff --git a/skills/kvb-org-sunset/scripts/night.py b/skills/kvb-org-suspend/scripts/night.py similarity index 65% rename from skills/kvb-org-sunset/scripts/night.py rename to skills/kvb-org-suspend/scripts/night.py index a713cd7..97e59e2 100755 --- a/skills/kvb-org-sunset/scripts/night.py +++ b/skills/kvb-org-suspend/scripts/night.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Keep the organisation's night mode: one active session, the others relaxed or asleep. +"""Keep the organisation's night mode: one active session, the others drained or suspended. Environment: AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host; @@ -7,16 +7,18 @@ Environment: CLAUDE_CODE_SESSION_ID: the calling session. Args: - set evening|sunset [--session ]: start the mode with the caller as the + drain|suspend [--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". + and the caller's part (active, resumed, drain or suspend). + resume [--session ]: end the mode; prints every other live session + as "\t". + resume --alone [--session ]: resume the caller alone while the others + stay in the mode; it then works like the active session. -A missing, unreadable or invalid state means day: every agent works. +A missing, unreadable or invalid state means day: every agent works. A state +written before the rename (mode evening or sunset, resumed sessions under +awake) reads as drain or suspend. Exit codes: 0 done, 2 usage error. @@ -37,7 +39,8 @@ 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)"} +PARTS = {"drain": "drain (kvb-agent-drain)", "suspend": "suspend (kvb-agent-suspend)"} +RENAMED = {"evening": "drain", "sunset": "suspend"} def council() -> ModuleType: @@ -47,18 +50,30 @@ def council() -> ModuleType: return module +def migrated(state: object) -> object: + if not isinstance(state, dict): + return state + state = dict(state) + if isinstance(state.get("mode"), str): + state["mode"] = RENAMED.get(state["mode"], state["mode"]) + if "awake" in state: + state.setdefault("resumed", state.pop("awake")) + return state + + def load() -> dict | None: try: - state = json.loads(STATE.read_text(encoding="utf-8")) + state = migrated(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 isinstance(state.get("mode"), str) + or state["mode"] not in PARTS or not state.get("active") - or not isinstance(state.get("awake", []), list) + or not isinstance(state.get("resumed", []), list) ): print(f"night: {STATE} is unreadable or invalid; treated as day", file=sys.stderr) return None @@ -76,11 +91,11 @@ def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int: live = cn.live_sessions() save( { - "mode": args.mode, + "mode": args.command, "active": sid, "name": live.get(sid), "since": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"), - "awake": [], + "resumed": [], } ) peers(live, sid) @@ -94,16 +109,15 @@ def save(state: dict) -> None: os.replace(handle.name, STATE) -def cmd_wake(args: argparse.Namespace, cn: ModuleType) -> int: - sid, _ = cn.caller(args.session) +def resume_alone(sid: str) -> int: 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] + if sid != state["active"] and sid not in state.get("resumed", []): + state["resumed"] = [*state.get("resumed", []), sid] save(state) - print(f"awake: {sid}") + print(f"resumed: {sid}") return 0 @@ -118,15 +132,17 @@ def cmd_status(args: argparse.Namespace, _: ModuleType) -> int: 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 in state.get("resumed", []): + print("you: resumed") elif sid: print(f"you: {PARTS[state['mode']]}") return 0 -def cmd_clear(args: argparse.Namespace, cn: ModuleType) -> int: +def cmd_resume(args: argparse.Namespace, cn: ModuleType) -> int: sid, _ = cn.caller(args.session) + if args.alone: + return resume_alone(sid) STATE.unlink(missing_ok=True) peers(cn.live_sessions(), sid) return 0 @@ -135,15 +151,13 @@ def cmd_clear(args: argparse.Namespace, cn: ModuleType) -> int: 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) + for mode in PARTS: + sub.add_parser(mode).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) + p = sub.add_parser("resume") + p.add_argument("--alone", action="store_true") + p.set_defaults(func=cmd_resume) for p in sub.choices.values(): p.add_argument("--session") args = parser.parse_args() diff --git a/skills/kvb-skill-cleanup/scripts/legacy.py b/skills/kvb-skill-cleanup/scripts/legacy.py index 3950394..d3ae509 100755 --- a/skills/kvb-skill-cleanup/scripts/legacy.py +++ b/skills/kvb-skill-cleanup/scripts/legacy.py @@ -2,7 +2,9 @@ """Classify installed skills as current, project, legacy, removed or unknown. Args: - --skills-repo: checkout of the skills repository, read with its git history. + --skills-repo: checkout of the skills repository, read with its git history; + a SKILL.md that keeps SIMILARITY of its text across a move counts as + renamed, since a renamed skill rewrites its own name throughout. --root: a skills directory to classify, repeatable. --json: print the rows as JSON instead of tab-separated lines. @@ -18,6 +20,8 @@ import subprocess import sys from pathlib import Path +SIMILARITY = "40%" + def fail(message: str) -> None: print(f"legacy: {message}", file=sys.stderr) @@ -40,7 +44,7 @@ def current_names(skills_repo: Path) -> set[str]: def moves(skills_repo: Path) -> dict[str, str | None]: out = git( skills_repo, - "log", "-M", "--diff-filter=RD", "--name-status", "--format=", "--", "skills/*/SKILL.md", + "log", f"-M{SIMILARITY}", "--diff-filter=RD", "--name-status", "--format=", "--", "skills/*/SKILL.md", ) if out is None: fail(f"{skills_repo} is not a git repository") diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index 4963193..06257ee 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -580,6 +580,44 @@ CLEAR_MAYDAY = """(async () => {@HELPERS@ const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Cancel mayday")), 15000); return {done, toasts: all("#toasts .toast").map(text)}; })()""".replace("@HELPERS@", HELPERS) +NIGHT_MENU = """(async () => {@HELPERS@ + const questions = []; + window.confirm = (question) => { + questions.push(question); + return true; + }; + let asked = 0; + const get = navigator.credentials.get.bind(navigator.credentials); + navigator.credentials.get = (options) => { + asked += 1; + return @SIGNS@ ? get(options) : Promise.reject(new DOMException("The operator canceled.", "NotAllowedError")); + }; + const posted = []; + const send = window.fetch.bind(window); + window.fetch = (path, init) => { + if (String(path).startsWith("/api/board/")) { + const body = JSON.parse(init.body); + posted.push([String(path), Object.keys(body), body.action || null]); + } + return send(path, init); + }; + const menu = await until(() => all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Day/Night"), 8000); + if (!menu) { + return {error: text(document.getElementById("main"))}; + } + menu.open = true; + const buttons = all(".actions button", menu); + const runs = []; + for (const button of buttons) { + const label = name(button); + const [before, prompts, sent] = [asked, questions.length, posted.length]; + const shown = new Set(all("#toasts .toast")); + button.click(); + const toast = await until(() => all("#toasts .toast").filter((box) => !shown.has(box)).map(text).find((entry) => entry.startsWith(`${label}:`)), 15000); + runs.push({label, question: questions.slice(prompts), asked: asked - before, posted: posted.slice(sent), toast}); + } + return {labels: buttons.map(name), runs}; +})()""".replace("@HELPERS@", HELPERS) def tone(seconds: int, rate: int) -> bytes: @@ -1048,7 +1086,7 @@ class TestOrchestratorE2E(HubCase): self.assertEqual((actions["settingsUnlabelled"], actions["popupUnlabelled"]), (0, 0)) self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"]) self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]]) - for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Evening", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"): + for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Drain", "Suspend", "Resume", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"): with self.subTest(tool=tool): self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"]) self.assertEqual(actions["popup"], [["post", True], ["call", False]]) @@ -1161,6 +1199,45 @@ class TestOrchestratorE2E(HubCase): self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result) self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])]) + def check_night_menu(self, browser: str, driver: str, binary: str, signs: bool) -> None: + """Args: + signs: cdp.mjs signs every prompt with its virtual passkey; bidi.mjs + has no virtual authenticator, so the probe answers each prompt + the way a canceled passkey dialog does. + """ + authenticator = self.authenticator() + probe = NIGHT_MENU.replace("@SIGNS@", "true" if signs else "false") + step = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#agents/orbit", "wait": 1200, "probe": probe, "shot": str(OUT / f"{browser}-1366-night-menu.png")} + data = self.drive([step], authenticator if signs else None, driver, binary) + (OUT / f"{browser}-night-menu.json").write_text(json.dumps(data, indent=1), encoding="utf-8") + self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], []) + found = data["results"][0] + self.assertNotIn("error", found) + self.assertEqual(found["labels"], ["Drain", "Suspend", "Resume"]) + for run, action in zip(found["runs"], ("drain", "suspend", "resume"), strict=True): + with self.subTest(browser=browser, action=action): + self.assertEqual(run["asked"], 1, run) + self.assertEqual(len(run["question"]), 1, run) + self.assertIn(f"kvb-agent-{action}", run["question"][0]) + signed = [["/api/board/sign", ["action"], action]] + if signs: + self.assertEqual(run["posted"], [*signed, [f"/api/board/{action}", ["proof"], None]]) + self.assertTrue((run["toast"] or "").startswith(f"{run['label']}: done"), run) + else: + self.assertEqual(run["posted"], signed) + self.assertTrue((run["toast"] or "").startswith(f"{run['label']}: not signed"), run) + nights = [args for script, args in self.scripts.calls if script == skill_scripts.NIGHT] + self.assertEqual(nights, [[action, "--session", "Operator"] for action in ("drain", "suspend", "resume")] if signs else []) + if not signs: + self.assertEqual(self.scripts.calls, []) + + def test_chromium_runs_drain_suspend_and_resume_only_after_the_passkey_signs(self): + self.check_night_menu("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), signs=True) + + @unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox") + def test_firefox_asks_the_passkey_for_drain_suspend_and_resume_and_runs_nothing_unsigned(self): + self.check_night_menu("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False) + def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self): tree = json.loads(self.tree_file.read_text(encoding="utf-8")) tree["delete_ready"], tree["delete_reason"] = True, "" diff --git a/tests/test_hub_board.py b/tests/test_hub_board.py index 1b73b08..224fe9d 100644 --- a/tests/test_hub_board.py +++ b/tests/test_hub_board.py @@ -3,14 +3,16 @@ from __future__ import annotations import dataclasses import http.server import json +import tempfile import threading import time import unittest +from pathlib import Path from unittest import mock from urllib.parse import quote from hub_case import HubCase -from hub_support import ASK, HOST_STATE, iso, key, server +from hub_support import ASK, HOST_STATE, REPO_ROOT, iso, key, server from kvb_orchestrator import handler, messaging, skill_scripts, transcripts @@ -67,11 +69,15 @@ class TestHubBoard(HubCase): now = time.time() self.write_council({ "radio-silence.json": {"since": now, "until": now + 60, "by": "Docs", "text": "disk full"}, - "night.json": {"mode": "evening", "active": "Operator", "awake": []}, + "night.json": {"mode": "drain", "active": "Operator", "resumed": []}, "rasci.json": {"work/a": {"worker": "A", "status": "running", "prio": 2}, "work/b": {"worker": "B session", "status": "queued", "prio": 1}, "work/c": {"worker": "C", "status": "done"}}, }) state = self.snapshot() - self.assertEqual((state["silence"]["by"], state["night"]), ("Docs", "evening")) + self.assertEqual((state["silence"]["by"], state["night"]), ("Docs", "drain")) + for old, new in (("evening", "drain"), ("sunset", "suspend"), (["sunset"], "day"), ("noon", "day")): + with self.subTest(old=old): + self.write_council({"night.json": {"mode": old, "active": "Operator", "awake": []}}) + self.assertEqual(self.snapshot()["night"], new) self.assertEqual(state["agent_order"], ["B session", "A", "Gatekeeper", "Skills session"]) self.write_council({"agent-order.json": {"agents": ["Gatekeeper", "Nobody", "A", "A"], "set": "2026-11-07T10:00:00+00:00"}}) self.assertEqual(self.snapshot()["agent_order"], ["Gatekeeper", "A", "B session", "Skills session"]) @@ -101,27 +107,33 @@ class TestHubBoard(HubCase): self.assertEqual(len(self.scripts.calls), calls) self.assertEqual((self.council / "rasci.json").read_text(encoding="utf-8"), register) calls = len(self.scripts.calls) - self.assertEqual(board("evening", signed("evening"))[0], 200) + self.assertEqual(board("drain", signed("drain"))[0], 200) self.assertEqual(self.scripts.calls[calls:], [ - (skill_scripts.NIGHT, ["set", "evening", "--session", "Operator"]), - (skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]), + (skill_scripts.NIGHT, ["drain", "--session", "Operator"]), + (skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["drain"][1]]), ]) - self.assertEqual(board("sunrise", signed("sunrise"))[0], 200) - self.assertEqual(self.scripts.calls[-2][1], ["clear", "--session", "Operator"]) + self.assertIn("run kvb-agent-drain;", messaging.NIGHTS["drain"][1]) + self.assertEqual(board("resume", signed("resume"))[0], 200) + self.assertEqual(self.scripts.calls[-2][1], ["resume", "--session", "Operator"]) + self.assertIn("run kvb-agent-resume;", self.scripts.calls[-1][1][-1]) self.scripts.code = 1 calls = len(self.scripts.calls) - status, result = board("sunset", signed("sunset")) - self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["set", "sunset", "--session", "Operator"])])) + status, result = board("suspend", signed("suspend")) + self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["suspend", "--session", "Operator"])])) self.assertEqual(board("reboot", {})[0], 404) + for old in ("evening", "sunset", "sunrise"): + with self.subTest(old=old): + self.assertEqual(board(old, {})[0], 404) self.assertEqual(board("broadcast", {"text": " ", "class": "newsletter", "voice": "", "proof": None})[0], 400) - self.assertEqual(self.post_json("/api/board/sunset", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403) - self.assertEqual(self.get("/api/board/sunset")[0], 405) + self.assertEqual(self.post_json("/api/board/suspend", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403) + self.assertEqual(self.get("/api/board/suspend")[0], 405) def test_board_actions_that_speak_for_the_operator_need_the_passkey(self): self.claim("s1", "Skills session") signer = self.pin() good = self.guard(self.host) - for action, other in (("clear-mayday", "sunrise"), ("evening", "sunset"), ("sunset", "evening"), ("sunrise", "clear-mayday")): + self.assertEqual(messaging.SIGNED_BOARD, ("clear-mayday", "drain", "suspend", "resume")) + for action, other in (("clear-mayday", "resume"), ("drain", "suspend"), ("suspend", "drain"), ("resume", "clear-mayday")): with self.subTest(action=action): self.assertEqual(self.post_json(f"/api/board/{action}", {}, good)[0], 400) self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"not signed: no signature\n")) @@ -134,13 +146,23 @@ class TestHubBoard(HubCase): self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[0], 200) self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])]) self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"not signed: this signature was already used for this item\n")) - for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": 1}, {}, {"action": "evening", "extra": 1}): + for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": "sunset"}, {"action": 1}, {}, {"action": "drain", "extra": 1}): with self.subTest(payload=payload): self.assertEqual(self.post_json("/api/board/sign", payload, good)[0], 400) - self.assertEqual(self.post_json("/api/board/sign", {"action": "evening"}, self.client())[0], 403) + self.assertEqual(self.post_json("/api/board/sign", {"action": "drain"}, self.client())[0], 403) self.assertEqual(self.get("/api/board/sign")[0], 405) self.assertEqual(len(self.scripts.calls), 1) + def test_every_night_action_is_a_command_the_shipped_night_script_takes(self): + with tempfile.TemporaryDirectory() as tmp: + scripts = skill_scripts.Scripts(skills=REPO_ROOT / "skills", council=Path(tmp) / "council", home=Path(tmp)) + for action, (command, _) in messaging.NIGHTS.items(): + with self.subTest(action=action): + run = scripts.run(skill_scripts.NIGHT, [*command, "--session", "Operator"]) + self.assertEqual(run["code"], 0, run["stderr"]) + status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "Operator"])["stdout"].splitlines() + self.assertEqual(status[0], "mode: day" if action == "resume" else f"mode: {action}") + def test_nl_actions_are_validated_against_a_faked_endpoint(self): status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host)) self.assertEqual(status, 400, body) diff --git a/tests/test_night.py b/tests/test_night.py index 6a111e2..28d9af6 100644 --- a/tests/test_night.py +++ b/tests/test_night.py @@ -1,4 +1,4 @@ -"""Exercise night.py: set, status per session, clear, and day on a bad state.""" +"""Exercise night.py: drain, suspend, status per session, resume, states from before the rename, and day on a bad state.""" from __future__ import annotations @@ -10,7 +10,7 @@ import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] -NIGHT = REPO_ROOT / "skills" / "kvb-org-sunset" / "scripts" / "night.py" +NIGHT = REPO_ROOT / "skills" / "kvb-org-suspend" / "scripts" / "night.py" class TestNight(unittest.TestCase): @@ -37,42 +37,62 @@ class TestNight(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) return result.stdout.splitlines() + def _state(self, state: dict) -> None: + self.root.mkdir(exist_ok=True) + (self.root / "night.json").write_text(json.dumps(state), encoding="utf-8") + 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"]) + def test_suspend_keeps_the_caller_active_and_suspends_the_others(self): + self.assertEqual(self._out("suspend"), ["s3\tThree", "s2\tTwo"]) lines = self._out("status", sid="s2") - self.assertEqual(lines[:2], ["mode: sunset", "active: One s1"]) + self.assertEqual(lines[:2], ["mode: suspend", "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(lines[3], "you: suspend (kvb-agent-suspend)") 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(self._out("status", "--session", "s3", sid=None)[3], "you: suspend (kvb-agent-suspend)") 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"]) + def test_drain_and_resume_return_to_day(self): + self._out("drain", sid="s2") + self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8"))["mode"], "drain") + self.assertEqual(self._out("status")[3], "you: drain (kvb-agent-drain)") + self.assertEqual(self._out("resume", 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_resume_alone_lifts_one_session_until_the_next_suspend(self): + self.assertEqual(self._out("resume", "--alone", sid="s2"), ["mode: day"]) + self._out("suspend") + self.assertEqual(self._out("resume", "--alone", sid="s2"), ["resumed: s2"]) + self._out("resume", "--alone", sid="s2") + self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8"))["resumed"], ["s2"]) + self.assertEqual(self._out("status", sid="s2")[3], "you: resumed") + self.assertEqual(self._out("status", sid="s3")[3], "you: suspend (kvb-agent-suspend)") + self._out("suspend") + self.assertEqual(self._out("status", sid="s2")[3], "you: suspend (kvb-agent-suspend)") + + def test_a_state_from_before_the_rename_keeps_working(self): + for old, new in (("evening", "drain"), ("sunset", "suspend")): + with self.subTest(old=old): + self._state({"mode": old, "active": "s1", "name": "One", "since": "2026-11-08T20:00:00+00:00", "awake": ["s3"]}) + self.assertEqual(self._out("status", sid="s2"), ["mode: " + new, "active: One s1", "since: 2026-11-08T20:00:00+00:00", f"you: {new} (kvb-agent-{new})"]) + self.assertEqual(self._out("status", sid="s3")[3], "you: resumed") + self.assertEqual(self._out("resume", "--alone", sid="s2"), ["resumed: s2"]) + state = json.loads((self.root / "night.json").read_text(encoding="utf-8")) + self.assertEqual((state["mode"], state["resumed"], "awake" in state), (new, ["s3", "s2"], False)) + self._state({"mode": "sunset", "active": "s1"}) + self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"]) + self.assertFalse((self.root / "night.json").exists()) 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": ["suspend"], "active": "s1"}), + json.dumps(["suspend"]), + json.dumps({"mode": "suspend", "active": "s1", "resumed": "s2"}), json.dumps({"mode": "sunset", "active": "s1", "awake": "s2"}), ): with self.subTest(text=text): @@ -82,8 +102,11 @@ class TestNight(unittest.TestCase): 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.assertEqual(self._run("suspend", sid=None).returncode, 2) + self.assertEqual(self._run("resume", "--alone", sid=None).returncode, 2) + for old in (("set", "sunset"), ("sunset",), ("evening",), ("clear",), ("wake",), ("noon",)): + with self.subTest(command=old): + self.assertEqual(self._run(*old).returncode, 2) self.assertFalse((self.root / "night.json").exists()) diff --git a/tests/test_night_names.py b/tests/test_night_names.py new file mode 100644 index 0000000..c119f58 --- /dev/null +++ b/tests/test_night_names.py @@ -0,0 +1,65 @@ +"""Pin decision 29: the six night skills renamed to drain, suspend and resume leave their old names nowhere but in a rename record.""" + +from __future__ import annotations + +import re +import subprocess +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +RENAMED = { + "kvb-org-evening": "kvb-org-drain", + "kvb-org-sunset": "kvb-org-suspend", + "kvb-org-sunrise": "kvb-org-resume", + "kvb-agent-relax": "kvb-agent-drain", + "kvb-agent-sleep": "kvb-agent-suspend", + "kvb-agent-wakeup": "kvb-agent-resume", +} +OLD = re.compile(r"(? list[str]: + """Returns: + Every `path:line` of a tracked or new, not ignored text file that names + an old night skill outside a rename record (`old` → `new`, as decision + 29 of docs/requirements/001-agent-orchestrator.md writes it). + """ + listed = subprocess.run( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + cwd=REPO_ROOT, capture_output=True, text=True, check=True, + ).stdout + hits = [] + for name in sorted(set(listed.split("\0")) - {"", SELF}): + try: + text = (REPO_ROOT / name).read_text(encoding="utf-8") + except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError): + continue + for number, line in enumerate(text.splitlines(), 1): + if OLD.search(RECORD.sub("", line)): + hits.append(f"{name}:{number}") + return hits + + +class TestNightNames(unittest.TestCase): + def test_the_six_new_skills_exist_and_the_old_ones_do_not(self): + for old, new in RENAMED.items(): + with self.subTest(skill=new): + self.assertTrue((REPO_ROOT / "skills" / new / "SKILL.md").is_file()) + self.assertFalse((REPO_ROOT / "skills" / old).exists()) + + def test_old_names_live_only_in_rename_records(self): + self.assertEqual(old_name_lines(), [], "write a rename as `old` → `new`; any other mention goes") + + def test_a_rename_record_is_the_only_exemption(self): + self.assertIsNone(OLD.search(RECORD.sub("", "`kvb-org-sunset` → `kvb-org-suspend`"))) + for line in ("run kvb-agent-sleep now", "`kvb-org-sunset`", "kvb-org-sunset → kvb-org-suspend", "skills/kvb-org-sunrise/SKILL.md"): + with self.subTest(line=line): + self.assertIsNotNone(OLD.search(RECORD.sub("", line))) + self.assertIsNone(OLD.search("kvb-org-sunsets kvb-agent-sleeper my-kvb-org-evening")) + + +if __name__ == "__main__": + print("\n".join(old_name_lines())) diff --git a/tests/test_skill_cleanup.py b/tests/test_skill_cleanup.py index 3dd3667..939d869 100644 --- a/tests/test_skill_cleanup.py +++ b/tests/test_skill_cleanup.py @@ -12,6 +12,17 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "skills" / "kvb-skill-cleanup" / "scripts" / "legacy.py" +REWORDED = """--- +name: {name} +description: probe +--- + +The probe keeps this first line as it is. +{name}: the first renamed line. +{name}: the second renamed line. +{name}: the third renamed line. +It keeps this closing line as it is too. +""" @unittest.skipUnless(shutil.which("git"), "git not installed") @@ -109,6 +120,24 @@ class TestLegacy(unittest.TestCase): ), ) + def test_a_rename_that_rewrites_its_name_throughout_keeps_its_successor(self): + (self.repo / "skills" / "night-old").mkdir() + (self.repo / "skills" / "night-old" / "SKILL.md").write_text(REWORDED.format(name="night-old"), encoding="utf-8") + self._commit(self.repo, "add night-old") + self._git(self.repo, "mv", "skills/night-old", "skills/kvb-x-night") + (self.repo / "skills" / "kvb-x-night" / "SKILL.md").write_text(REWORDED.format(name="kvb-x-night"), encoding="utf-8") + self._commit(self.repo, "rename night-old to kvb-x-night") + default = subprocess.run( + ["git", "diff", "-M", "--diff-filter=R", "--name-only", "HEAD~1", "HEAD"], + cwd=self.repo, env=self.env, capture_output=True, text=True, check=True, + ) + self.assertEqual(default.stdout.strip(), "", "the probe must stay below git's default rename similarity") + self._skill(self.home / "night-old") + result = self._run("--skills-repo", str(self.repo), "--root", str(self.home), "--json") + self.assertEqual(result.returncode, 0, result.stderr) + rows = {Path(row["path"]).name: (row["verdict"], row["successor"]) for row in json.loads(result.stdout)} + self.assertEqual(rows["night-old"], ("legacy", "kvb-x-night")) + def test_invalid_input_exits_2(self): missing = self._run("--skills-repo", str(self.repo), "--root", str(self.home / "nope")) self.assertEqual(missing.returncode, 2)