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, ""
+37 -15
View File
@@ -3,14 +3,16 @@ from __future__ import annotations
import dataclasses
import http.server
import json
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
from urllib.parse import quote
from hub_case import HubCase
from hub_support import ASK, HOST_STATE, iso, key, server
from hub_support import ASK, HOST_STATE, REPO_ROOT, iso, key, server
from kvb_orchestrator import handler, messaging, skill_scripts, transcripts
@@ -67,11 +69,15 @@ class TestHubBoard(HubCase):
now = time.time()
self.write_council({
"radio-silence.json": {"since": now, "until": now + 60, "by": "Docs", "text": "disk full"},
"night.json": {"mode": "evening", "active": "Operator", "awake": []},
"night.json": {"mode": "drain", "active": "Operator", "resumed": []},
"rasci.json": {"work/a": {"worker": "A", "status": "running", "prio": 2}, "work/b": {"worker": "B session", "status": "queued", "prio": 1}, "work/c": {"worker": "C", "status": "done"}},
})
state = self.snapshot()
self.assertEqual((state["silence"]["by"], state["night"]), ("Docs", "evening"))
self.assertEqual((state["silence"]["by"], state["night"]), ("Docs", "drain"))
for old, new in (("evening", "drain"), ("sunset", "suspend"), (["sunset"], "day"), ("noon", "day")):
with self.subTest(old=old):
self.write_council({"night.json": {"mode": old, "active": "Operator", "awake": []}})
self.assertEqual(self.snapshot()["night"], new)
self.assertEqual(state["agent_order"], ["B session", "A", "Gatekeeper", "Skills session"])
self.write_council({"agent-order.json": {"agents": ["Gatekeeper", "Nobody", "A", "A"], "set": "2026-11-07T10:00:00+00:00"}})
self.assertEqual(self.snapshot()["agent_order"], ["Gatekeeper", "A", "B session", "Skills session"])
@@ -101,27 +107,33 @@ class TestHubBoard(HubCase):
self.assertEqual(len(self.scripts.calls), calls)
self.assertEqual((self.council / "rasci.json").read_text(encoding="utf-8"), register)
calls = len(self.scripts.calls)
self.assertEqual(board("evening", signed("evening"))[0], 200)
self.assertEqual(board("drain", signed("drain"))[0], 200)
self.assertEqual(self.scripts.calls[calls:], [
(skill_scripts.NIGHT, ["set", "evening", "--session", "Operator"]),
(skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]),
(skill_scripts.NIGHT, ["drain", "--session", "Operator"]),
(skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["drain"][1]]),
])
self.assertEqual(board("sunrise", signed("sunrise"))[0], 200)
self.assertEqual(self.scripts.calls[-2][1], ["clear", "--session", "Operator"])
self.assertIn("run kvb-agent-drain;", messaging.NIGHTS["drain"][1])
self.assertEqual(board("resume", signed("resume"))[0], 200)
self.assertEqual(self.scripts.calls[-2][1], ["resume", "--session", "Operator"])
self.assertIn("run kvb-agent-resume;", self.scripts.calls[-1][1][-1])
self.scripts.code = 1
calls = len(self.scripts.calls)
status, result = board("sunset", signed("sunset"))
self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["set", "sunset", "--session", "Operator"])]))
status, result = board("suspend", signed("suspend"))
self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["suspend", "--session", "Operator"])]))
self.assertEqual(board("reboot", {})[0], 404)
for old in ("evening", "sunset", "sunrise"):
with self.subTest(old=old):
self.assertEqual(board(old, {})[0], 404)
self.assertEqual(board("broadcast", {"text": " ", "class": "newsletter", "voice": "", "proof": None})[0], 400)
self.assertEqual(self.post_json("/api/board/sunset", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403)
self.assertEqual(self.get("/api/board/sunset")[0], 405)
self.assertEqual(self.post_json("/api/board/suspend", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403)
self.assertEqual(self.get("/api/board/suspend")[0], 405)
def test_board_actions_that_speak_for_the_operator_need_the_passkey(self):
self.claim("s1", "Skills session")
signer = self.pin()
good = self.guard(self.host)
for action, other in (("clear-mayday", "sunrise"), ("evening", "sunset"), ("sunset", "evening"), ("sunrise", "clear-mayday")):
self.assertEqual(messaging.SIGNED_BOARD, ("clear-mayday", "drain", "suspend", "resume"))
for action, other in (("clear-mayday", "resume"), ("drain", "suspend"), ("suspend", "drain"), ("resume", "clear-mayday")):
with self.subTest(action=action):
self.assertEqual(self.post_json(f"/api/board/{action}", {}, good)[0], 400)
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"not signed: no signature\n"))
@@ -134,13 +146,23 @@ class TestHubBoard(HubCase):
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[0], 200)
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"not signed: this signature was already used for this item\n"))
for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": 1}, {}, {"action": "evening", "extra": 1}):
for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": "sunset"}, {"action": 1}, {}, {"action": "drain", "extra": 1}):
with self.subTest(payload=payload):
self.assertEqual(self.post_json("/api/board/sign", payload, good)[0], 400)
self.assertEqual(self.post_json("/api/board/sign", {"action": "evening"}, self.client())[0], 403)
self.assertEqual(self.post_json("/api/board/sign", {"action": "drain"}, self.client())[0], 403)
self.assertEqual(self.get("/api/board/sign")[0], 405)
self.assertEqual(len(self.scripts.calls), 1)
def test_every_night_action_is_a_command_the_shipped_night_script_takes(self):
with tempfile.TemporaryDirectory() as tmp:
scripts = skill_scripts.Scripts(skills=REPO_ROOT / "skills", council=Path(tmp) / "council", home=Path(tmp))
for action, (command, _) in messaging.NIGHTS.items():
with self.subTest(action=action):
run = scripts.run(skill_scripts.NIGHT, [*command, "--session", "Operator"])
self.assertEqual(run["code"], 0, run["stderr"])
status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "Operator"])["stdout"].splitlines()
self.assertEqual(status[0], "mode: day" if action == "resume" else f"mode: {action}")
def test_nl_actions_are_validated_against_a_faked_endpoint(self):
status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host))
self.assertEqual(status, 400, body)
+47 -24
View File
@@ -1,4 +1,4 @@
"""Exercise night.py: set, status per session, clear, and day on a bad state."""
"""Exercise night.py: drain, suspend, status per session, resume, states from before the rename, and day on a bad state."""
from __future__ import annotations
@@ -10,7 +10,7 @@ import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
NIGHT = REPO_ROOT / "skills" / "kvb-org-sunset" / "scripts" / "night.py"
NIGHT = REPO_ROOT / "skills" / "kvb-org-suspend" / "scripts" / "night.py"
class TestNight(unittest.TestCase):
@@ -37,42 +37,62 @@ class TestNight(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.splitlines()
def _state(self, state: dict) -> None:
self.root.mkdir(exist_ok=True)
(self.root / "night.json").write_text(json.dumps(state), encoding="utf-8")
def test_day_by_default(self):
self.assertEqual(self._out("status", sid="s2"), ["mode: day"])
def test_sunset_keeps_the_caller_active_and_sends_the_others_to_sleep(self):
self.assertEqual(self._out("set", "sunset"), ["s3\tThree", "s2\tTwo"])
def test_suspend_keeps_the_caller_active_and_suspends_the_others(self):
self.assertEqual(self._out("suspend"), ["s3\tThree", "s2\tTwo"])
lines = self._out("status", sid="s2")
self.assertEqual(lines[:2], ["mode: sunset", "active: One s1"])
self.assertEqual(lines[:2], ["mode: suspend", "active: One s1"])
self.assertRegex(lines[2], r"^since: \d{4}-\d\d-\d\dT")
self.assertEqual(lines[3], "you: sleep (kvb-agent-sleep)")
self.assertEqual(lines[3], "you: suspend (kvb-agent-suspend)")
self.assertEqual(self._out("status")[3], "you: active")
self.assertEqual(self._out("status", "--session", "s3", sid=None)[3], "you: sleep (kvb-agent-sleep)")
self.assertEqual(self._out("status", "--session", "s3", sid=None)[3], "you: suspend (kvb-agent-suspend)")
self.assertEqual(len(self._out("status", sid=None)), 3)
def test_evening_relaxes_and_clear_returns_to_day(self):
self._out("set", "evening", sid="s2")
self.assertEqual(self._out("status")[3], "you: relax (kvb-agent-relax)")
self.assertEqual(self._out("clear", sid="s2"), ["s1\tOne", "s3\tThree"])
def test_drain_and_resume_return_to_day(self):
self._out("drain", sid="s2")
self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8"))["mode"], "drain")
self.assertEqual(self._out("status")[3], "you: drain (kvb-agent-drain)")
self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"])
self.assertEqual(self._out("status"), ["mode: day"])
def test_wake_lifts_one_session_until_the_next_set(self):
self.assertEqual(self._out("wake", sid="s2"), ["mode: day"])
self._out("set", "sunset")
self.assertEqual(self._out("wake", sid="s2"), ["awake: s2"])
self._out("wake", sid="s2")
self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8"))["awake"], ["s2"])
self.assertEqual(self._out("status", sid="s2")[3], "you: awake")
self.assertEqual(self._out("status", sid="s3")[3], "you: sleep (kvb-agent-sleep)")
self._out("set", "sunset")
self.assertEqual(self._out("status", sid="s2")[3], "you: sleep (kvb-agent-sleep)")
def test_resume_alone_lifts_one_session_until_the_next_suspend(self):
self.assertEqual(self._out("resume", "--alone", sid="s2"), ["mode: day"])
self._out("suspend")
self.assertEqual(self._out("resume", "--alone", sid="s2"), ["resumed: s2"])
self._out("resume", "--alone", sid="s2")
self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8"))["resumed"], ["s2"])
self.assertEqual(self._out("status", sid="s2")[3], "you: resumed")
self.assertEqual(self._out("status", sid="s3")[3], "you: suspend (kvb-agent-suspend)")
self._out("suspend")
self.assertEqual(self._out("status", sid="s2")[3], "you: suspend (kvb-agent-suspend)")
def test_a_state_from_before_the_rename_keeps_working(self):
for old, new in (("evening", "drain"), ("sunset", "suspend")):
with self.subTest(old=old):
self._state({"mode": old, "active": "s1", "name": "One", "since": "2026-11-08T20:00:00+00:00", "awake": ["s3"]})
self.assertEqual(self._out("status", sid="s2"), ["mode: " + new, "active: One s1", "since: 2026-11-08T20:00:00+00:00", f"you: {new} (kvb-agent-{new})"])
self.assertEqual(self._out("status", sid="s3")[3], "you: resumed")
self.assertEqual(self._out("resume", "--alone", sid="s2"), ["resumed: s2"])
state = json.loads((self.root / "night.json").read_text(encoding="utf-8"))
self.assertEqual((state["mode"], state["resumed"], "awake" in state), (new, ["s3", "s2"], False))
self._state({"mode": "sunset", "active": "s1"})
self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"])
self.assertFalse((self.root / "night.json").exists())
def test_bad_state_means_day(self):
self.root.mkdir()
for text in (
"{not json",
json.dumps({"mode": "noon", "active": "s1"}),
json.dumps(["sunset"]),
json.dumps({"mode": ["suspend"], "active": "s1"}),
json.dumps(["suspend"]),
json.dumps({"mode": "suspend", "active": "s1", "resumed": "s2"}),
json.dumps({"mode": "sunset", "active": "s1", "awake": "s2"}),
):
with self.subTest(text=text):
@@ -82,8 +102,11 @@ class TestNight(unittest.TestCase):
self.assertIn("treated as day", result.stderr)
def test_usage_errors_exit_2(self):
self.assertEqual(self._run("set", "sunset", sid=None).returncode, 2)
self.assertEqual(self._run("set", "noon").returncode, 2)
self.assertEqual(self._run("suspend", sid=None).returncode, 2)
self.assertEqual(self._run("resume", "--alone", sid=None).returncode, 2)
for old in (("set", "sunset"), ("sunset",), ("evening",), ("clear",), ("wake",), ("noon",)):
with self.subTest(command=old):
self.assertEqual(self._run(*old).returncode, 2)
self.assertFalse((self.root / "night.json").exists())
+65
View File
@@ -0,0 +1,65 @@
"""Pin decision 29: the six night skills renamed to drain, suspend and resume leave their old names nowhere but in a rename record."""
from __future__ import annotations
import re
import subprocess
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RENAMED = {
"kvb-org-evening": "kvb-org-drain",
"kvb-org-sunset": "kvb-org-suspend",
"kvb-org-sunrise": "kvb-org-resume",
"kvb-agent-relax": "kvb-agent-drain",
"kvb-agent-sleep": "kvb-agent-suspend",
"kvb-agent-wakeup": "kvb-agent-resume",
}
OLD = re.compile(r"(?<![\w-])(?:" + "|".join(map(re.escape, RENAMED)) + r")(?![\w-])")
RECORD = re.compile(r"`(?:" + "|".join(map(re.escape, RENAMED)) + r")` →")
SELF = Path(__file__).resolve().relative_to(REPO_ROOT).as_posix()
def old_name_lines() -> list[str]:
"""Returns:
Every `path:line` of a tracked or new, not ignored text file that names
an old night skill outside a rename record (`old` → `new`, as decision
29 of docs/requirements/001-agent-orchestrator.md writes it).
"""
listed = subprocess.run(
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
cwd=REPO_ROOT, capture_output=True, text=True, check=True,
).stdout
hits = []
for name in sorted(set(listed.split("\0")) - {"", SELF}):
try:
text = (REPO_ROOT / name).read_text(encoding="utf-8")
except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError):
continue
for number, line in enumerate(text.splitlines(), 1):
if OLD.search(RECORD.sub("", line)):
hits.append(f"{name}:{number}")
return hits
class TestNightNames(unittest.TestCase):
def test_the_six_new_skills_exist_and_the_old_ones_do_not(self):
for old, new in RENAMED.items():
with self.subTest(skill=new):
self.assertTrue((REPO_ROOT / "skills" / new / "SKILL.md").is_file())
self.assertFalse((REPO_ROOT / "skills" / old).exists())
def test_old_names_live_only_in_rename_records(self):
self.assertEqual(old_name_lines(), [], "write a rename as `old` → `new`; any other mention goes")
def test_a_rename_record_is_the_only_exemption(self):
self.assertIsNone(OLD.search(RECORD.sub("", "`kvb-org-sunset` → `kvb-org-suspend`")))
for line in ("run kvb-agent-sleep now", "`kvb-org-sunset`", "kvb-org-sunset → kvb-org-suspend", "skills/kvb-org-sunrise/SKILL.md"):
with self.subTest(line=line):
self.assertIsNotNone(OLD.search(RECORD.sub("", line)))
self.assertIsNone(OLD.search("kvb-org-sunsets kvb-agent-sleeper my-kvb-org-evening"))
if __name__ == "__main__":
print("\n".join(old_name_lines()))
+29
View File
@@ -12,6 +12,17 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-skill-cleanup" / "scripts" / "legacy.py"
REWORDED = """---
name: {name}
description: probe
---
The probe keeps this first line as it is.
{name}: the first renamed line.
{name}: the second renamed line.
{name}: the third renamed line.
It keeps this closing line as it is too.
"""
@unittest.skipUnless(shutil.which("git"), "git not installed")
@@ -109,6 +120,24 @@ class TestLegacy(unittest.TestCase):
),
)
def test_a_rename_that_rewrites_its_name_throughout_keeps_its_successor(self):
(self.repo / "skills" / "night-old").mkdir()
(self.repo / "skills" / "night-old" / "SKILL.md").write_text(REWORDED.format(name="night-old"), encoding="utf-8")
self._commit(self.repo, "add night-old")
self._git(self.repo, "mv", "skills/night-old", "skills/kvb-x-night")
(self.repo / "skills" / "kvb-x-night" / "SKILL.md").write_text(REWORDED.format(name="kvb-x-night"), encoding="utf-8")
self._commit(self.repo, "rename night-old to kvb-x-night")
default = subprocess.run(
["git", "diff", "-M", "--diff-filter=R", "--name-only", "HEAD~1", "HEAD"],
cwd=self.repo, env=self.env, capture_output=True, text=True, check=True,
)
self.assertEqual(default.stdout.strip(), "", "the probe must stay below git's default rename similarity")
self._skill(self.home / "night-old")
result = self._run("--skills-repo", str(self.repo), "--root", str(self.home), "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rows = {Path(row["path"]).name: (row["verdict"], row["successor"]) for row in json.loads(result.stdout)}
self.assertEqual(rows["night-old"], ("legacy", "kvb-x-night"))
def test_invalid_input_exits_2(self):
missing = self._run("--skills-repo", str(self.repo), "--root", str(self.home / "nope"))
self.assertEqual(missing.returncode, 2)