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 🏷️
|
## 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
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]+)*$")
|
||||||
|
|||||||
Reference in new issue
Block a user