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:
1 parent
57c3322076
commit
5f30644ca6
9 files changed
+541
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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]+)*$")
|
||||
|
||||
Reference in new issue
Block a user