diff --git a/README.md b/README.md index 918d0c3..76ab9ea 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline ## Naming 🏷️ -Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `skill`. 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--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `skill`, `coach`. 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 πŸ›οΈ @@ -26,6 +26,7 @@ flowchart LR kvb --> check[check] kvb --> org[org] kvb --> skill[skill] + kvb --> coach[coach] kvb --> none[no category] think --> think_listen[kvb-think-listen] think --> think_explain[kvb-think-explain] @@ -77,6 +78,7 @@ flowchart LR none --> dream_all[kvb-dream] none --> optimize_all[kvb-optimize] none --> coach_all[kvb-coach] + coach --> coach_meditation[kvb-coach-meditation] ``` ## Skill map πŸ—ΊοΈ @@ -161,6 +163,9 @@ flowchart LR optimize[kvb-optimize] coach[kvb-coach] end + subgraph cat_coach [coach] + meditation[kvb-coach-meditation] + end commit_req -.-> commit_next commit_req -.-> commit_no_verify @@ -245,6 +250,8 @@ flowchart LR coach --> active_listening coach --> operator coach --> rasci + coach --> meditation + meditation --> active_listening autoskill --> autotune autotune --> essence transcend --> skill_transcend diff --git a/skills/kvb-ci-triage/SKILL.md b/skills/kvb-ci-triage/SKILL.md index d4370e0..57457c4 100644 --- a/skills/kvb-ci-triage/SKILL.md +++ b/skills/kvb-ci-triage/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-ci-triage description: > - Triage a failing CI run, pipeline or job set to a verified root cause and a - real fix, one cluster per worktree. Trigger when a build, test or deploy - pipeline has failed jobs. + Triage a failing CI run to a verified root cause and a real fix, one + cluster per worktree. Trigger when a build, test or deploy pipeline has + failed jobs. --- Drive a red pipeline to green by root cause, not by guesswork. Treat every diff --git a/skills/kvb-coach-meditation/SKILL.md b/skills/kvb-coach-meditation/SKILL.md new file mode 100644 index 0000000..7b06963 --- /dev/null +++ b/skills/kvb-coach-meditation/SKILL.md @@ -0,0 +1,55 @@ +--- +name: kvb-coach-meditation +description: > + Short meditation with generative alpha-wave music, animated fractals and + breathing cues, tuned by feedback. Trigger on /kvb-coach-meditation. +--- + +A short session in the browser: `assets/meditation.html` plays generative +ambient music over an alpha-wave layer (8–13 Hz) and animates a Julia fractal +that breathes with the cue. It runs locally, without audio files or network. + +`${CLAUDE_SKILL_DIR}/scripts/meditation.py` keeps the settings in the +gitignored `.state/meditation.json` of the skills repository and writes the +session page with the settings embedded: + +```bash +meditation.py --state /.state/meditation.json set = ... +meditation.py --state show +meditation.py --state session --page ${CLAUDE_SKILL_DIR}/assets/meditation.html --out /.state/meditation-session.html +``` + +| Key | Values | First session | +| --- | --- | --- | +| `minutes` | 1–30 | 5 | +| `beat` | 8–13 Hz | 10 | +| `base` | 100–400 Hz | 196 | +| `volume` | 0.05–1 | 0.4 | +| `mode` | `isochronic` (speakers), `binaural` (headphones) | `isochronic` | +| `palette` | `aurora`, `ocean`, `ember`, `forest` | `aurora` | +| `breathing` | `box` (4-4-4-4), `478`, `none` | `box` | + +## Step 1: start + +Without a state file, `set` the first-session values. Write the session page +and hand the operator `xdg-open ` to run: inside the agent +sandbox `xdg-open` and `gio open` exit 0 without opening anything. The page starts on the +operator's click, shows the elapsed time as a line growing along the bottom +edge, and fades out after the set minutes; `+` and `-` set the +picture's brightness (20–160 %), which the browser keeps for the next session. + +## Step 2: feedback + +After every session, run the `kvb-think-listen` skill with one +`AskUserQuestion`, `multiSelect: false` per question: music (calmer, fits, +livelier, quieter, louder), picture (fits, calmer, other palette), length +(shorter, fits, longer), effect (calmer, more focused, nothing, restless). +Ask about headphones once before switching to `binaural`. + +## Step 3: optimize + +One `set` per answer: quieter or louder Β±0.1 `volume`; calmer music `beat` +βˆ’1, more focus `beat` +1; shorter or longer Β±2 `minutes`; other palette the +next one; restless `breathing=none`. Log the session in the `kvb-coach` +profile (`coach.py done psyche "Meditation min"`) and a liked effect with +`coach.py like psyche`. diff --git a/skills/kvb-coach-meditation/assets/meditation.html b/skills/kvb-coach-meditation/assets/meditation.html new file mode 100644 index 0000000..83d3044 --- /dev/null +++ b/skills/kvb-coach-meditation/assets/meditation.html @@ -0,0 +1,260 @@ + + + + + +kvb-coach-meditation + + + + +
+
+
+
+ + + diff --git a/skills/kvb-coach-meditation/scripts/meditation.py b/skills/kvb-coach-meditation/scripts/meditation.py new file mode 100755 index 0000000..4bf02b9 --- /dev/null +++ b/skills/kvb-coach-meditation/scripts/meditation.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Keep the kvb-coach-meditation settings and write the session page. + +Args: + --state: path of the settings JSON file. + set = ...: change settings; every value is validated. + show: print the settings. + session --page --out : write the page with the + settings embedded and print its path; fails while a setting is missing. + +Exit codes: + 0 done, 2 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +PLACEHOLDER = "const SETTINGS = null;" +RANGES = { + "minutes": (int, 1, 30), + "beat": (float, 8.0, 13.0), + "base": (float, 100.0, 400.0), + "volume": (float, 0.05, 1.0), +} +CHOICES = { + "mode": ("binaural", "isochronic"), + "palette": ("aurora", "ocean", "ember", "forest"), + "breathing": ("box", "478", "none"), +} +KEYS = (*RANGES, *CHOICES) + + +def fail(message: str) -> None: + print(f"meditation: {message}", file=sys.stderr) + sys.exit(2) + + +def parse(pair: str) -> tuple[str, int | float | str]: + key, sep, raw = pair.partition("=") + if not sep or key not in KEYS: + fail(f"{pair}: expected = with key in {', '.join(KEYS)}") + if key in CHOICES: + if raw not in CHOICES[key]: + fail(f"{key} must be one of {', '.join(CHOICES[key])}") + return key, raw + kind, low, high = RANGES[key] + try: + value = kind(raw) + except ValueError: + fail(f"{key} must be a number") + if not low <= value <= high: + fail(f"{key} must lie between {low} and {high}") + return key, value + + +def load(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except ValueError: + fail(f"{path} is not valid JSON") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--state", required=True) + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("set").add_argument("pairs", nargs="+") + sub.add_parser("show") + p = sub.add_parser("session") + p.add_argument("--page", required=True) + p.add_argument("--out", required=True) + args = parser.parse_args() + + path = Path(args.state) + settings = load(path) + if args.cmd == "set": + settings.update(parse(pair) for pair in args.pairs) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(settings, indent=1, sort_keys=True), encoding="utf-8") + tmp.replace(path) + if args.cmd in ("set", "show"): + for key in KEYS: + print(f"{key}={settings.get(key, '-')}") + return 0 + missing = [key for key in KEYS if key not in settings] + if missing: + fail(f"missing settings: {', '.join(missing)}") + page = Path(args.page) + if not page.is_file(): + fail(f"{page} does not exist") + template = page.read_text(encoding="utf-8") + if template.count(PLACEHOLDER) != 1: + fail(f"{page} lacks the line {PLACEHOLDER}") + embedded = json.dumps({key: settings[key] for key in KEYS}) + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(template.replace(PLACEHOLDER, f"const SETTINGS = {embedded};"), encoding="utf-8") + print(out.resolve()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/kvb-coach/SKILL.md b/skills/kvb-coach/SKILL.md index 04c9f8b..81181ee 100644 --- a/skills/kvb-coach/SKILL.md +++ b/skills/kvb-coach/SKILL.md @@ -18,7 +18,8 @@ coach.py --profile done "" [--at ] coach.py --profile show [--json] ``` -Areas: `psyche`, `body`, `sleep`, `food`, `tasks`, `delegate`. +Areas: `psyche`, `body`, `sleep`, `food`, `tasks`, `delegate`. Every coaching +skill beyond this one is named `kvb-coach-`. ## Step 1: read the situation @@ -36,8 +37,9 @@ Areas: `psyche`, `body`, `sleep`, `food`, `tasks`, `delegate`. At most one line per area, the most urgent first, the whole answer on one screen: -- 🧠 **Psyche**: a short break, a walk outside, five minutes of meditation or - box breathing, a call with a friend, something just for fun. +- 🧠 **Psyche**: a short break, a walk outside, five minutes with the + `kvb-coach-meditation` skill or box breathing, a call with a friend, + something just for fun. - πŸ’ͺ **Body**: a workout from the operator's likes (a 20-minute run, a bodyweight circuit, cycling, yoga), stretching neck, hips and back after long sitting, standing up every hour. diff --git a/skills/kvb-code-tune/SKILL.md b/skills/kvb-code-tune/SKILL.md index aaf2b69..e498822 100644 --- a/skills/kvb-code-tune/SKILL.md +++ b/skills/kvb-code-tune/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-code-tune description: > - Find and apply performance optimizations (CI, deploy, build, gate, hot - paths) without losing logic or coverage. Trigger on /kvb-code-tune, a job - hitting its time budget, a slower pipeline, or asked where time goes. + Speed up CI, deploys, builds and hot paths without losing logic or + coverage. Trigger on /kvb-code-tune, a job hitting its time budget, or + asked where time goes. --- # Tune diff --git a/tests/test_meditation.py b/tests/test_meditation.py new file mode 100644 index 0000000..5b50631 --- /dev/null +++ b/tests/test_meditation.py @@ -0,0 +1,88 @@ +"""Exercise meditation.py and keep it in step with the keys and choices of meditation.html.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SKILL = REPO_ROOT / "skills" / "kvb-coach-meditation" +SCRIPT = SKILL / "scripts" / "meditation.py" +PAGE = SKILL / "assets" / "meditation.html" +FULL = [ + "minutes=5", + "beat=10", + "base=196", + "volume=0.4", + "mode=isochronic", + "palette=aurora", + "breathing=box", +] + + +class TestMeditation(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.state = Path(self.tmp.name) / "state" / "meditation.json" + + def _run(self, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(SCRIPT), "--state", str(self.state), *args], + check=False, + capture_output=True, + text=True, + ) + + def _session(self) -> subprocess.CompletedProcess: + out = Path(self.tmp.name) / "session.html" + return self._run("session", "--page", str(PAGE), "--out", str(out)) + + def test_session_page_embeds_every_setting(self): + self.assertEqual(self._run("set", *FULL).returncode, 0) + result = self._session() + self.assertEqual(result.returncode, 0, result.stderr) + page = Path(result.stdout.strip()).read_text(encoding="utf-8") + self.assertNotIn("const SETTINGS = null;", page) + embedded = json.loads(re.search(r"const SETTINGS = (\{.*?\});", page).group(1)) + self.assertEqual(embedded["beat"], 10.0) + self.assertEqual(embedded["minutes"], 5) + self.assertEqual(set(embedded), {pair.split("=")[0] for pair in FULL}) + + def test_session_refuses_missing_settings(self): + self._run("set", "minutes=5") + self.assertEqual(self._session().returncode, 2) + self.assertFalse((Path(self.tmp.name) / "session.html").exists()) + + def test_invalid_values_exit_2_and_keep_the_state(self): + self._run("set", *FULL) + before = json.loads(self.state.read_text(encoding="utf-8")) + for pair in ("beat=20", "mode=loud", "minutes=five", "tempo=3", "volume"): + with self.subTest(pair=pair): + self.assertEqual(self._run("set", "minutes=7", pair).returncode, 2) + self.assertEqual(json.loads(self.state.read_text(encoding="utf-8")), before) + + +class TestPageMatchesScript(unittest.TestCase): + def test_keys_and_choices_agree(self): + spec = importlib.util.spec_from_file_location("meditation", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + page = PAGE.read_text(encoding="utf-8") + self.assertEqual(page.count(module.PLACEHOLDER), 1) + keys = re.search(r"const KEYS = \[(.*?)\];", page).group(1) + self.assertEqual(re.findall(r'"(\w+)"', keys), list(module.KEYS)) + for name, choices in (("PALETTES", "palette"), ("PATTERNS", "breathing")): + block = re.search(rf"const {name} = \{{(.*?)\n\}};", page, re.DOTALL).group(1) + found = re.findall(r'^\s*"?(\w+)"?:', block, re.MULTILINE) + self.assertEqual(sorted(found), sorted(module.CHOICES[choices])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_naming.py b/tests/test_naming.py index b501d33..a4ef450 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -9,9 +9,16 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SKILLS = REPO_ROOT / "skills" README = REPO_ROOT / "README.md" -CATEGORIES = ("think", "create", "code", "ci", "git", "check", "org", "skill") +CATEGORIES = ("think", "create", "code", "ci", "git", "check", "org", "skill", "coach") UNCATEGORIZED = frozenset( - {"kvb-cleanup", "kvb-coach", "kvb-dream", "kvb-help", "kvb-optimize", "kvb-transcend"} + { + "kvb-cleanup", + "kvb-coach", + "kvb-dream", + "kvb-help", + "kvb-optimize", + "kvb-transcend", + } ) SPEC = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") SCHEMA = re.compile(rf"^kvb-(?:{'|'.join(CATEGORIES)})-[a-z0-9]+(?:-[a-z0-9]+)*$")