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

+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, ""