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 🏷️ ## 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 🏛️ ## Organigram 🏛️
@@ -26,6 +26,7 @@ flowchart LR
kvb --> check[check] kvb --> check[check]
kvb --> org[org] kvb --> org[org]
kvb --> skill[skill] kvb --> skill[skill]
kvb --> coach[coach]
kvb --> none[no category] kvb --> none[no category]
think --> think_listen[kvb-think-listen] think --> think_listen[kvb-think-listen]
think --> think_explain[kvb-think-explain] think --> think_explain[kvb-think-explain]
@@ -77,6 +78,7 @@ flowchart LR
none --> dream_all[kvb-dream] none --> dream_all[kvb-dream]
none --> optimize_all[kvb-optimize] none --> optimize_all[kvb-optimize]
none --> coach_all[kvb-coach] none --> coach_all[kvb-coach]
coach --> coach_meditation[kvb-coach-meditation]
``` ```
## Skill map 🗺️ ## Skill map 🗺️
@@ -161,6 +163,9 @@ flowchart LR
optimize[kvb-optimize] optimize[kvb-optimize]
coach[kvb-coach] coach[kvb-coach]
end end
subgraph cat_coach [coach]
meditation[kvb-coach-meditation]
end
commit_req -.-> commit_next commit_req -.-> commit_next
commit_req -.-> commit_no_verify commit_req -.-> commit_no_verify
@@ -245,6 +250,8 @@ flowchart LR
coach --> active_listening coach --> active_listening
coach --> operator coach --> operator
coach --> rasci coach --> rasci
coach --> meditation
meditation --> active_listening
autoskill --> autotune autoskill --> autotune
autotune --> essence autotune --> essence
transcend --> skill_transcend transcend --> skill_transcend
+3 -3
View File
@@ -1,9 +1,9 @@
--- ---
name: kvb-ci-triage name: kvb-ci-triage
description: > description: >
Triage a failing CI run, pipeline or job set to a verified root cause and a Triage a failing CI run to a verified root cause and a real fix, one
real fix, one cluster per worktree. Trigger when a build, test or deploy cluster per worktree. Trigger when a build, test or deploy pipeline has
pipeline has failed jobs. failed jobs.
--- ---
Drive a red pipeline to green by root cause, not by guesswork. Treat every 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] 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 ## 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 At most one line per area, the most urgent first, the whole answer on one
screen: screen:
- 🧠 **Psyche**: a short break, a walk outside, five minutes of meditation or - 🧠 **Psyche**: a short break, a walk outside, five minutes with the
box breathing, a call with a friend, something just for fun. `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 - 💪 **Body**: a workout from the operator's likes (a 20-minute run, a
bodyweight circuit, cycling, yoga), stretching neck, hips and back after bodyweight circuit, cycling, yoga), stretching neck, hips and back after
long sitting, standing up every hour. long sitting, standing up every hour.
+3 -3
View File
@@ -1,9 +1,9 @@
--- ---
name: kvb-code-tune name: kvb-code-tune
description: > description: >
Find and apply performance optimizations (CI, deploy, build, gate, hot Speed up CI, deploys, builds and hot paths without losing logic or
paths) without losing logic or coverage. Trigger on /kvb-code-tune, a job coverage. Trigger on /kvb-code-tune, a job hitting its time budget, or
hitting its time budget, a slower pipeline, or asked where time goes. asked where time goes.
--- ---
# Tune # 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] REPO_ROOT = Path(__file__).resolve().parents[1]
SKILLS = REPO_ROOT / "skills" SKILLS = REPO_ROOT / "skills"
README = REPO_ROOT / "README.md" 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( 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]+)*$") SPEC = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
SCHEMA = re.compile(rf"^kvb-(?:{'|'.join(CATEGORIES)})-[a-z0-9]+(?:-[a-z0-9]+)*$") SCHEMA = re.compile(rf"^kvb-(?:{'|'.join(CATEGORIES)})-[a-z0-9]+(?:-[a-z0-9]+)*$")