feat(skills)!: S4 night commands become drain, suspend and resume

Requirement 001 phase 6, S4 (decision 29):

- Skills 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; their text uses the new verbs and
  every reference in skills, README, orchestrator and tests follows.
- night.py takes drain, suspend, resume, resume --alone and status. A
  state file written before the rename keeps working: mode evening reads
  as drain, sunset as suspend, the awake list as resumed and is rewritten
  on the next save; the hub's council.night_mode maps the same values.
  mode: day and handover_night_<session>.md keep their names.
- Orchestrator: the Day/Night menu shows Drain, Suspend and Resume, each
  confirmed, signed through /api/board/sign with the passkey and run as
  the existing argv job; the old action names are refused.
- kvb-skill-cleanup: git rates two renames at 44 and 50 % similarity,
  under its default threshold, so legacy.py detects renames from 40 %.
- tests/test_night_names.py fails on any old name in the tree outside a
  rename record "old ->".

BREAKING CHANGE: the six old skill names and night.py's clear and wake
are gone. Run make install (it reinstalls the orchestrator too), then
kvb-skill-cleanup to remove the old installed copies; until the install,
the running hub calls the old night.py path.

Evidence: make test 679 OK, ruff and markdownlint clean
(/tmp/s4-make-test-1.log); e2e 22 OK in Chromium and Firefox
(/tmp/s4-e2e-3.log); MEDUSA 23 files, the 2 findings are the
byte-identical atomic state write that moved with night.py, already
accepted after a skeptic at its old path, rescan 0 open.

🎯 Fix: 93 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-08 23:18:18 +00:00
1 parent 4d88296e47
commit 5c8b69d53d
24 files changed
+420 -176

No files matched your search

+20 -20
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` 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-<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 (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
+2 -2
View File
@@ -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)
+11 -1
View File
@@ -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"
+3 -3
View File
@@ -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"]}
@@ -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"
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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};
}
+2 -2
View File
@@ -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"};
+17
View File
@@ -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 <sender> "drained: <what still runs>"`. Resuming is the
`kvb-agent-resume` skill.
-17
View File
@@ -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 <sender> "relaxed: <what still runs>"`. Waking up is the
`kvb-agent-wakeup` skill.
@@ -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_<session id>.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 <sender>
"awake: <what resumes>"`.
"resumed: <what resumes>"`.
@@ -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: <handover path>`, by
5. **Reply** to the sender in one line, `suspended: <handover path>`, by
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call --to
<sender>` (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.
@@ -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 <session>` per printed session, since each must act, first
line:
"Operator ran kvb-org-evening in <name>: run kvb-agent-relax; verify with
"Operator ran kvb-org-drain in <name>: 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
+3 -3
View File
@@ -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/<slug>`, 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.
@@ -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
+3 -3
View File
@@ -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
@@ -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 <name>` 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
kvb-org-suspend in <name>: 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
<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.
- **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
@@ -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 <id>]: start the mode with the caller as the
drain|suspend [--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>".
and the caller's part (active, resumed, drain or suspend).
resume [--session <id>]: end the mode; prints every other live session
as "<id>\t<name>".
resume --alone [--session <id>]: 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()
+6 -2
View File
@@ -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")
+78 -1
View File
@@ -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, ""
+37 -15
View File
@@ -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)
+47 -24
View File
@@ -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())
+65
View File
@@ -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"(?<![\w-])(?:" + "|".join(map(re.escape, RENAMED)) + r")(?![\w-])")
RECORD = re.compile(r"`(?:" + "|".join(map(re.escape, RENAMED)) + r")` →")
SELF = Path(__file__).resolve().relative_to(REPO_ROOT).as_posix()
def old_name_lines() -> 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()))
+29
View File
@@ -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)