feat(kvb-coach-meditation): short alpha-wave meditation with fractals, tuned by feedback

kvb-coach-meditation writes a local session page with its settings embedded (xdg-open drops a file:// query): generative pentatonic ambience with reverb over an isochronic or binaural alpha layer (8-13 Hz), a WebGL Julia fractal that zooms with the breathing cue (box, 4-7-8 or none), breath-synced glowing type in URW Gothic, +/- keys for the picture's brightness kept in localStorage, and a progress line along the bottom edge. meditation.py validates every setting and refuses a session while one is missing; after every session kvb-think-listen collects feedback that becomes one set per answer, logged in the kvb-coach profile. Adds the category coach: every coaching skill beyond kvb-coach is named kvb-coach-<name>. tests/test_meditation.py keeps script and page in step. Operator order 2026-11-03; two descriptions trimmed for the listing budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 16:14:45 +01:00
1 parent 57c3322076
commit 5f30644ca6
9 files changed
+541 -12

No files matched your search

+8 -1
View File
@@ -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`, `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-<category>-<name>` 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
+3 -3
View File
@@ -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
+55
View File
@@ -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 <skills repo>/.state/meditation.json set <key>=<value> ...
meditation.py --state <same> show
meditation.py --state <same> session --page ${CLAUDE_SKILL_DIR}/assets/meditation.html --out <skills repo>/.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 <session page>` 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 <n> min"`) and a liked effect with
`coach.py like psyche`.
@@ -0,0 +1,260 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>kvb-coach-meditation</title>
<style>
html, body { margin: 0; height: 100%; background: #000; color: #eee; font-family: "URW Gothic", "Fira Sans", "Cantarell", sans-serif; font-weight: 300; overflow: hidden; }
canvas { position: fixed; inset: 0; width: 100%; height: 100%; }
#ui { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; text-align: center; padding: 1rem; }
#breath { font-size: 2.6rem; text-transform: lowercase; letter-spacing: .4em; color: #fff; text-shadow: 0 0 14px #fff9, 0 0 40px #9cf8, 0 0 80px #c9f5; will-change: transform, opacity, letter-spacing; }
#clock { position: fixed; bottom: 1rem; right: 1rem; opacity: .5; font-variant-numeric: tabular-nums; }
#progress { position: fixed; left: 0; bottom: 0; height: 3px; width: 0; background: linear-gradient(90deg, #9cf, #c9f); box-shadow: 0 0 12px #9cf9; opacity: .7; }
#level { position: fixed; top: 1rem; right: 1rem; opacity: 0; transition: opacity .6s; letter-spacing: .2em; text-shadow: 0 0 10px #fff8; }
button { font: inherit; font-size: 1.4rem; letter-spacing: .4em; text-transform: lowercase; padding: .8rem 2.4rem; border-radius: 2rem; border: 1px solid #fff6; background: #fff1; color: #fff; cursor: pointer; text-shadow: 0 0 12px #fff8; }
</style>
</head>
<body>
<canvas id="view"></canvas>
<div id="ui"><div id="breath"></div><button id="start">Start</button></div>
<div id="clock"></div>
<div id="level"></div>
<div id="progress"></div>
<script>
"use strict";
const KEYS = ["minutes", "beat", "base", "volume", "mode", "palette", "breathing"];
const PALETTES = {
aurora: [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.0, 0.33, 0.67]],
ocean: [[0.2, 0.4, 0.6], [0.2, 0.3, 0.4], [1.0, 1.0, 1.0], [0.0, 0.1, 0.2]],
ember: [[0.5, 0.3, 0.2], [0.5, 0.3, 0.2], [1.0, 1.0, 1.0], [0.0, 0.1, 0.2]],
forest: [[0.3, 0.5, 0.3], [0.2, 0.4, 0.2], [1.0, 1.0, 1.0], [0.1, 0.2, 0.3]],
};
const PATTERNS = {
box: [["Breathe in", 4], ["Hold", 4], ["Breathe out", 4], ["Hold", 4]],
"478": [["Breathe in", 4], ["Hold", 7], ["Breathe out", 8]],
none: [],
};
const SETTINGS = null;
const breathEl = document.getElementById("breath");
const clockEl = document.getElementById("clock");
const startEl = document.getElementById("start");
const missing = KEYS.filter((key) => !SETTINGS || !(key in SETTINGS));
const cfg = Object.fromEntries(KEYS.map((key) => [key, SETTINGS ? SETTINGS[key] : null]));
["minutes", "beat", "base", "volume"].forEach((key) => { cfg[key] = Number(cfg[key]); });
if (missing.length || !PALETTES[cfg.palette] || !PATTERNS[cfg.breathing]) {
startEl.remove();
breathEl.textContent = "Start the session through the kvb-coach-meditation skill.";
throw new Error(`invalid settings: ${missing.join(", ")}`);
}
function breath(t) {
const steps = PATTERNS[cfg.breathing];
if (!steps.length) return ["", 0.5 + 0.5 * Math.sin(t * 0.2)];
const total = steps.reduce((sum, [, d]) => sum + d, 0);
let x = t % total;
let lung = 0;
for (const [label, d] of steps) {
if (x < d) {
if (label === "Breathe in") return [label, x / d];
if (label === "Breathe out") return [label, 1 - x / d];
return [label, lung];
}
x -= d;
if (label === "Breathe in") lung = 1;
if (label === "Breathe out") lung = 0;
}
return ["", lung];
}
const canvas = document.getElementById("view");
const gl = canvas.getContext("webgl");
let draw = () => {};
if (gl) {
const VERT = "attribute vec2 p; void main() { gl_Position = vec4(p, 0.0, 1.0); }";
const FRAG = `precision highp float;
uniform vec2 res; uniform float t; uniform float lung; uniform vec3 pa, pb, pc, pd;
vec3 pal(float x) { return pa + pb * cos(6.28318 * (pc * x + pd)); }
void main() {
vec2 z = (2.0 * gl_FragCoord.xy - res) / min(res.x, res.y) * (1.6 - 0.3 * lung);
vec2 c = 0.7885 * vec2(cos(t * 0.05), sin(t * 0.05));
float n = 0.0;
for (int i = 0; i < 160; i++) {
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
if (dot(z, z) > 16.0) break;
n += 1.0;
}
float s = (n - log2(log2(max(dot(z, z), 1.0001))) + 4.0) / 160.0;
gl_FragColor = vec4(pal(s + t * 0.01) * (n < 160.0 ? 1.0 : 0.05), 1.0);
}`;
const shader = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const prog = gl.createProgram();
gl.attachShader(prog, shader(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "p");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const u = (name) => gl.getUniformLocation(prog, name);
["pa", "pb", "pc", "pd"].forEach((name, i) => gl.uniform3fv(u(name), PALETTES[cfg.palette][i]));
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.floor(innerWidth * scale);
canvas.height = Math.floor(innerHeight * scale);
gl.viewport(0, 0, canvas.width, canvas.height);
};
addEventListener("resize", resize);
resize();
draw = (t, lung) => {
gl.uniform2f(u("res"), canvas.width, canvas.height);
gl.uniform1f(u("t"), t);
gl.uniform1f(u("lung"), lung);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
draw(0, 0);
}
const levelEl = document.getElementById("level");
const progressEl = document.getElementById("progress");
const BRIGHTNESS = "kvb-coach-meditation-brightness";
const stored = () => {
try {
return Number(localStorage.getItem(BRIGHTNESS)) || 1;
} catch {
return 1;
}
};
const store = (value) => {
try {
localStorage.setItem(BRIGHTNESS, String(value));
return true;
} catch {
return false;
}
};
let brightness = stored();
let levelTimer = 0;
const showBrightness = () => {
canvas.style.filter = `brightness(${brightness})`;
};
showBrightness();
addEventListener("keydown", (event) => {
const step = { "+": 0.1, "=": 0.1, "-": -0.1 }[event.key];
if (!step) return;
brightness = Math.min(1.6, Math.max(0.2, Math.round((brightness + step) * 10) / 10));
showBrightness();
const saved = store(brightness);
levelEl.textContent = `☀ ${Math.round(brightness * 100)} %${saved ? "" : " (not saved)"}`;
levelEl.style.opacity = "1";
clearTimeout(levelTimer);
levelTimer = setTimeout(() => { levelEl.style.opacity = "0"; }, 1200);
});
function sound(ctx) {
const master = ctx.createGain();
master.gain.setValueAtTime(0, ctx.currentTime);
master.gain.linearRampToValueAtTime(cfg.volume, ctx.currentTime + 8);
master.connect(ctx.destination);
const reverb = ctx.createConvolver();
const length = ctx.sampleRate * 4;
const impulse = ctx.createBuffer(2, length, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 3);
}
reverb.buffer = impulse;
reverb.connect(master);
if (cfg.mode === "binaural") {
const merger = ctx.createChannelMerger(2);
[cfg.base, cfg.base + cfg.beat].forEach((freq, ch) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = freq;
gain.gain.value = 0.12;
osc.connect(gain).connect(merger, 0, ch);
osc.start();
});
merger.connect(master);
} else {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const lfo = ctx.createOscillator();
const depth = ctx.createGain();
osc.frequency.value = cfg.base;
gain.gain.value = 0.06;
lfo.frequency.value = cfg.beat;
depth.gain.value = 0.06;
lfo.connect(depth).connect(gain.gain);
osc.connect(gain).connect(master);
osc.start();
lfo.start();
}
const ratios = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
const note = () => {
if (ctx.state === "closed") return;
const now = ctx.currentTime;
const freq = cfg.base * ratios[Math.floor(Math.random() * ratios.length)];
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.1, now + 3);
gain.gain.linearRampToValueAtTime(0, now + 9);
gain.connect(reverb);
[0, 4].forEach((cents) => {
const osc = ctx.createOscillator();
osc.type = "triangle";
osc.frequency.value = freq;
osc.detune.value = cents;
osc.connect(gain);
osc.start(now);
osc.stop(now + 9.5);
});
setTimeout(note, 2500 + Math.random() * 3500);
};
note();
return master;
}
startEl.addEventListener("click", () => {
startEl.remove();
const ctx = new AudioContext();
const master = sound(ctx);
const begin = performance.now();
const end = cfg.minutes * 60;
const frame = (now) => {
const t = (now - begin) / 1000;
const [label, lung] = breath(t);
draw(t, lung);
progressEl.style.width = `${Math.min(100, (t / end) * 100)}%`;
const left = Math.max(0, end - t);
clockEl.textContent = `${Math.floor(left / 60)}:${String(Math.floor(left % 60)).padStart(2, "0")}`;
breathEl.style.opacity = String(0.35 + 0.65 * lung);
breathEl.style.letterSpacing = `${0.3 + 0.5 * lung}em`;
breathEl.style.transform = `scale(${0.9 + 0.2 * lung})`;
if (t < end) {
breathEl.textContent = label;
requestAnimationFrame(frame);
return;
}
master.gain.cancelScheduledValues(ctx.currentTime);
master.gain.setValueAtTime(master.gain.value, ctx.currentTime);
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 6);
setTimeout(() => ctx.close(), 6500);
breathEl.style.opacity = "1";
breathEl.style.transform = "none";
breathEl.style.letterSpacing = ".2em";
breathEl.textContent = "Done. Back to your agent for a short feedback.";
};
requestAnimationFrame(frame);
});
</script>
</body>
</html>
+110
View File
@@ -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 <key>=<value> ...: change settings; every value is validated.
show: print the settings.
session --page <meditation.html> --out <file>: 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 <key>=<value> 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())
+5 -3
View File
@@ -18,7 +18,8 @@ coach.py --profile <same> done <area> "<thing>" [--at <iso>]
coach.py --profile <same> 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-<name>`.
## 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.
+3 -3
View File
@@ -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
+88
View File
@@ -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()
+9 -2
View File
@@ -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]+)*$")