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:
1 parent
4d88296e47
commit
5c8b69d53d
24 files changed
+420
-176
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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()))
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user