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
@@ -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