@cullumco/cadence 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ import { exec } from "node:child_process";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { tagsToVibe } from "../vibe.js";
6
+ /* ─────────────────────────────────────────────────────────────────────────
7
+ * Music = identity + vibe. No Spotify Web API, no auth, no Premium.
8
+ *
9
+ * 1. osascript asks Spotify.app / Music.app what's playing (public,
10
+ * stable scripting interface — survives the macOS 15.4 MediaRemote
11
+ * lockdown that killed the system-wide now-playing tap).
12
+ * 2. MusicBrainz turns the artist into crowd-sourced vibe tags
13
+ * (keyless, 1 req/sec). Cached forever by artist — a vibe never
14
+ * changes, so it's one network call per *new* artist, not per prompt.
15
+ * ───────────────────────────────────────────────────────────────────────── */
16
+ const CACHE_FILE = join(homedir(), ".cadence", "vibe-cache.json");
17
+ const MB_TIMEOUT_MS = 1000;
18
+ const MAX_TAGS = 4;
19
+ const UA = "cadence/0.1 (https://github.com/cullumco/cadence)";
20
+ // `application "X" is running` does NOT launch X — safe to probe.
21
+ const SCRIPT = `
22
+ on tryApp(appName)
23
+ if application appName is running then
24
+ tell application appName
25
+ if player state is playing then
26
+ return appName & "|||" & (name of current track) & "|||" & (artist of current track)
27
+ end if
28
+ end tell
29
+ end if
30
+ return ""
31
+ end tryApp
32
+ set r to tryApp("Spotify")
33
+ if r is "" then set r to tryApp("Music")
34
+ return r
35
+ `;
36
+ function osascript(script) {
37
+ return new Promise((resolve) => {
38
+ const child = exec(`osascript -e ${JSON.stringify(script)}`, { timeout: 800 }, (err, stdout) => resolve(err ? "" : stdout.trim()));
39
+ child.on("error", () => resolve(""));
40
+ });
41
+ }
42
+ async function getNowPlaying() {
43
+ const out = await osascript(SCRIPT);
44
+ if (!out)
45
+ return null;
46
+ const [player, track, artist] = out.split("|||");
47
+ if (!track || !artist)
48
+ return null;
49
+ return { track, artist, player: player ?? "" };
50
+ }
51
+ async function loadCache() {
52
+ try {
53
+ return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
59
+ async function saveCache(cache) {
60
+ try {
61
+ await mkdir(join(homedir(), ".cadence"), { recursive: true });
62
+ await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
63
+ }
64
+ catch {
65
+ // cache is best-effort; never let it break the signal
66
+ }
67
+ }
68
+ // Returns CLEANED genre tags (junk filtered out), most-popular first.
69
+ // We cache the tags, not the derived vibe — so tuning the vibe mapping
70
+ // (tagsToVibe / GENRE_AFFECT) takes effect immediately without flushing the cache.
71
+ async function fetchTags(artist) {
72
+ const ctrl = new AbortController();
73
+ const timer = setTimeout(() => ctrl.abort(), MB_TIMEOUT_MS);
74
+ try {
75
+ const url = "https://musicbrainz.org/ws/2/artist/?fmt=json&limit=1&query=" +
76
+ encodeURIComponent(`artist:"${artist}"`);
77
+ const res = await fetch(url, {
78
+ headers: { "User-Agent": UA },
79
+ signal: ctrl.signal,
80
+ });
81
+ if (!res.ok)
82
+ return null;
83
+ const data = (await res.json());
84
+ const tags = data.artists?.[0]?.tags;
85
+ if (!tags || tags.length === 0)
86
+ return null;
87
+ const cleaned = tags
88
+ .filter((t) => t.count > 0)
89
+ .sort((a, b) => b.count - a.count)
90
+ .map((t) => t.name)
91
+ .filter((name) => isVibeTag(name, artist))
92
+ .slice(0, MAX_TAGS);
93
+ return cleaned.length ? cleaned : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+ /* A "vibe" is an adjective or genre — never a proper noun, place, or
103
+ * listener-org meta-tag. Blocklist (not allowlist) so novel genres
104
+ * (hyperpop, phonk, …) pass; we only reject known classes of junk. */
105
+ const META_TAG = /^(seen live|favou?rites?|spotify|owned|albums i own|under \d+|my |male |female )/;
106
+ const PLACES = new Set([
107
+ "toronto", "london", "uk", "usa", "us", "american", "british", "canadian",
108
+ "swedish", "german", "french", "australian", "japanese", "korean",
109
+ "english", "scottish", "irish", "norwegian", "icelandic", "dutch",
110
+ ]);
111
+ function isVibeTag(tag, artist) {
112
+ const t = tag.toLowerCase().trim();
113
+ if (t.length < 2 || t.length > 30)
114
+ return false; // empty or essay-length
115
+ const nameWords = new Set(artist.toLowerCase().split(/\s+/));
116
+ if (nameWords.has(t))
117
+ return false; // "daniel", "caesar"
118
+ if (PLACES.has(t))
119
+ return false; // geography is trivia, not vibe
120
+ if (META_TAG.test(t))
121
+ return false; // listener-org cruft
122
+ return true;
123
+ }
124
+ // Cache stores the cleaned tags as a comma-joined string (""=known-empty).
125
+ async function getTags(artist) {
126
+ const key = artist.toLowerCase();
127
+ const cache = await loadCache();
128
+ if (key in cache) {
129
+ const v = cache[key] ?? "";
130
+ return v ? v.split(",") : [];
131
+ }
132
+ const tags = await fetchTags(artist);
133
+ cache[key] = (tags ?? []).join(","); // cache empty too — don't re-hit MB every prompt
134
+ await saveCache(cache);
135
+ return tags ?? [];
136
+ }
137
+ export async function getMusicSignal() {
138
+ const np = await getNowPlaying();
139
+ if (!np)
140
+ return null;
141
+ const tags = await getTags(np.artist);
142
+ const vibe = tags.length ? tagsToVibe(tags) : null;
143
+ return {
144
+ source: "music",
145
+ track: np.track,
146
+ artist: np.artist,
147
+ player: np.player || undefined,
148
+ vibe: vibe && vibe.moods.length ? vibe.moods.join(", ") : undefined,
149
+ energy: vibe?.energy,
150
+ };
151
+ }
@@ -0,0 +1,19 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const STATE_FILE = join(homedir(), ".cadence", "state.txt");
5
+ const STALE_AFTER_MS = 4 * 60 * 60 * 1000;
6
+ export async function getSelfReportSignal() {
7
+ try {
8
+ const [text, info] = await Promise.all([readFile(STATE_FILE, "utf-8"), stat(STATE_FILE)]);
9
+ const trimmed = text.trim();
10
+ if (!trimmed)
11
+ return null;
12
+ if (Date.now() - info.mtimeMs > STALE_AFTER_MS)
13
+ return null;
14
+ return { source: "self_report", text: trimmed, setAt: info.mtimeMs };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
package/dist/stop.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { getMusicSignal } from "./providers/music.js";
4
+ import { getSelfReportSignal } from "./providers/selfreport.js";
5
+ import { getAmbientSignal } from "./providers/ambient.js";
6
+ import { getGitSignal } from "./providers/git.js";
7
+ import { deriveCadence, loadOverrides, applyOverrides } from "./cadence.js";
8
+ const TOTAL_BUDGET_MS = 1500;
9
+ async function readStdin() {
10
+ if (process.stdin.isTTY)
11
+ return {};
12
+ let raw = "";
13
+ for await (const chunk of process.stdin)
14
+ raw += chunk;
15
+ try {
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ async function collectSignals(cwd) {
23
+ const [music, report, ambient, git] = await Promise.allSettled([
24
+ getMusicSignal(),
25
+ getSelfReportSignal(),
26
+ getAmbientSignal(new Date()),
27
+ getGitSignal(cwd),
28
+ ]);
29
+ const signals = [];
30
+ if (music.status === "fulfilled" && music.value)
31
+ signals.push(music.value);
32
+ if (report.status === "fulfilled" && report.value)
33
+ signals.push(report.value);
34
+ if (ambient.status === "fulfilled" && ambient.value)
35
+ signals.push(ambient.value);
36
+ if (git.status === "fulfilled" && git.value)
37
+ signals.push(git.value);
38
+ return signals;
39
+ }
40
+ function selfReportIsShipping(signals) {
41
+ const report = signals.find((s) => s.source === "self_report");
42
+ if (!report)
43
+ return false;
44
+ return /\b(ship|shipping|jamming|locked.?in|sending|grind|just|send it)\b/i.test(report.text);
45
+ }
46
+ function pinnedActFreely(cadence, pinned) {
47
+ return ((pinned.includes("proactivity") && cadence.proactivity === "high") ||
48
+ (pinned.includes("posture") && cadence.posture === "high"));
49
+ }
50
+ function hasShippingAuthority(signals, cadence, pinned) {
51
+ return selfReportIsShipping(signals) || pinnedActFreely(cadence, pinned);
52
+ }
53
+ export function isSoftHandoff(message) {
54
+ const text = message.trim().toLowerCase();
55
+ if (!text)
56
+ return false;
57
+ const tail = text.slice(-900);
58
+ const finalParagraph = tail.split(/\n\s*\n/).at(-1) ?? tail;
59
+ const permissionQuestion = /\b(?:do you want|would you like|want me to|should i|shall i|can i|would you prefer|should we)\b/.test(finalParagraph) && /\?\s*$/.test(finalParagraph);
60
+ const passiveOffer = /\b(?:let me know if|tell me if|say the word|if you want,? i can|if you'd like,? i can|happy to keep going|happy to do that next)\b/.test(finalParagraph);
61
+ return permissionQuestion || passiveOffer;
62
+ }
63
+ export function decideStop(input, signals, cadence, pinned) {
64
+ if (input.stop_hook_active)
65
+ return null;
66
+ if (input.background_tasks && input.background_tasks.length > 0)
67
+ return null;
68
+ if (!hasShippingAuthority(signals, cadence, pinned))
69
+ return null;
70
+ if (!isSoftHandoff(input.last_assistant_message ?? ""))
71
+ return null;
72
+ return {
73
+ decision: "block",
74
+ reason: "Cadence stop check: the user is in a shipping / act-freely cadence, but your last response ended as a soft handoff. Continue instead: make the call and complete the most likely next step, or if no tool work remains, replace the handoff with a decisive final answer. Do not ask permission unless there is a genuine blocker.",
75
+ };
76
+ }
77
+ async function main() {
78
+ const input = await readStdin();
79
+ const projectDir = input.cwd ?? process.cwd();
80
+ const [signals, overrides] = await Promise.all([
81
+ Promise.race([
82
+ collectSignals(projectDir),
83
+ new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS)),
84
+ ]),
85
+ loadOverrides(),
86
+ ]);
87
+ const state = { signals, capturedAt: Date.now() };
88
+ const { cadence, pinned } = applyOverrides(deriveCadence(state), overrides);
89
+ const decision = decideStop(input, signals, cadence, pinned);
90
+ if (decision)
91
+ process.stdout.write(JSON.stringify(decision));
92
+ }
93
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
94
+ main().catch((err) => {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ process.stderr.write(`cadence stop: ${msg}\n`);
97
+ process.exit(0);
98
+ });
99
+ }
package/dist/types.js ADDED
@@ -0,0 +1,16 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * Cadence signal types.
3
+ *
4
+ * Music is now IDENTITY-ONLY. Spotify's audio-features endpoint was
5
+ * deprecated for new apps (2024-11-27) and dev-mode went Premium-only
6
+ * (2026-02), so energy/valence/tempo are gone. Track + artist come from
7
+ * the OS now-playing channel instead — no auth, no Premium, any player.
8
+ *
9
+ * The four embodied dimensions, each a provider emitting one Signal:
10
+ * - MusicSignal → what's playing (music)
11
+ * - SelfReportSignal → what you told us (mood, ground truth)
12
+ * - ActivitySignal → your motor/typing tempo (mood, inferred)
13
+ * - GitSignal → your work state (context)
14
+ * - PlaceSignal → where & in what setting (place)
15
+ * ───────────────────────────────────────────────────────────────────────── */
16
+ export {};
package/dist/vibe.js ADDED
@@ -0,0 +1,96 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * vibe.ts — turn noisy genre tags into (a) clean mood words and (b) an
3
+ * averaged energy (0–1). The energy feeds the PACE dial in cadence.ts; the
4
+ * mood words color the rendered block and the TONE dial.
5
+ *
6
+ * Grounded in deep research (see docs/vibe-research.md):
7
+ * • Mood vocabulary = Cyanite.ai's verified 13-mood controlled set.
8
+ * • Affect axes = Spotify's published feature definitions:
9
+ * energy (0–1) → intensity → pace dial (fast ↔ deliberate)
10
+ * valence (0–1) → positiveness → which mood word (happy ↔ sad)
11
+ * acoustic → mellow/organic → warms the tone dial
12
+ * • No published genre→affect TABLE exists, so the table below is
13
+ * hand-authored from genre knowledge. Extensible — add rows as artists miss.
14
+ *
15
+ * Pipeline: raw tags → match GENRE_AFFECT rows → average energy/valence/acoustic
16
+ * → pick mood words. The signal→dial mapping itself lives in cadence.ts.
17
+ * ───────────────────────────────────────────────────────────────────────── */
18
+ /* Hand-authored genre→affect table. Keys are lowercase substrings matched
19
+ * against the incoming tags. Values are rough but defensible per the research
20
+ * (energy: death-metal high → Bach low; valence: euphoric high → depressed low).
21
+ * Order doesn't matter — every matching row is averaged. */
22
+ const GENRE_AFFECT = {
23
+ // ── high energy → ship ───────────────────────────────────────────────
24
+ punk: { energy: 0.9, valence: 0.6, acoustic: 0.05, moods: ["aggressive", "energetic"] },
25
+ metal: { energy: 0.95, valence: 0.4, acoustic: 0.02, moods: ["aggressive", "dark"] },
26
+ hardcore: { energy: 0.95, valence: 0.45, acoustic: 0.02, moods: ["aggressive", "energetic"] },
27
+ "drum and bass": { energy: 0.9, valence: 0.6, acoustic: 0.03, moods: ["energetic"] },
28
+ "drum & bass": { energy: 0.9, valence: 0.6, acoustic: 0.03, moods: ["energetic"] },
29
+ techno: { energy: 0.85, valence: 0.55, acoustic: 0.02, moods: ["energetic"] },
30
+ house: { energy: 0.8, valence: 0.7, acoustic: 0.05, moods: ["energetic", "uplifting"] },
31
+ "dance": { energy: 0.82, valence: 0.75, acoustic: 0.05, moods: ["energetic", "happy"] },
32
+ "electronic": { energy: 0.75, valence: 0.6, acoustic: 0.05, moods: ["energetic"] },
33
+ "hip hop": { energy: 0.75, valence: 0.6, acoustic: 0.1, moods: ["energetic"] },
34
+ "hip-hop": { energy: 0.75, valence: 0.6, acoustic: 0.1, moods: ["energetic"] },
35
+ rap: { energy: 0.75, valence: 0.55, acoustic: 0.1, moods: ["energetic"] },
36
+ rock: { energy: 0.78, valence: 0.55, acoustic: 0.15, moods: ["energetic"] },
37
+ pop: { energy: 0.72, valence: 0.78, acoustic: 0.15, moods: ["happy", "uplifting"] },
38
+ funk: { energy: 0.75, valence: 0.8, acoustic: 0.1, moods: ["happy", "energetic"] },
39
+ disco: { energy: 0.8, valence: 0.85, acoustic: 0.1, moods: ["happy", "uplifting"] },
40
+ // ── mid / groovy → ship-or-think depending on energy ─────────────────
41
+ "r&b": { energy: 0.55, valence: 0.6, acoustic: 0.2, moods: ["sexy", "chilled"] },
42
+ soul: { energy: 0.55, valence: 0.65, acoustic: 0.25, moods: ["romantic", "uplifting"] },
43
+ reggae: { energy: 0.6, valence: 0.75, acoustic: 0.2, moods: ["chilled", "happy"] },
44
+ indie: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
45
+ // ── low energy, organic → think ──────────────────────────────────────
46
+ ambient: { energy: 0.2, valence: 0.5, acoustic: 0.6, moods: ["ethereal", "calm"] },
47
+ "lo-fi": { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
48
+ lofi: { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
49
+ chillout: { energy: 0.3, valence: 0.55, acoustic: 0.4, moods: ["chilled", "calm"] },
50
+ chill: { energy: 0.35, valence: 0.55, acoustic: 0.4, moods: ["chilled"] },
51
+ downtempo: { energy: 0.4, valence: 0.5, acoustic: 0.35, moods: ["chilled", "ethereal"] },
52
+ "trip hop": { energy: 0.45, valence: 0.4, acoustic: 0.3, moods: ["dark", "chilled"] },
53
+ "trip-hop": { energy: 0.45, valence: 0.4, acoustic: 0.3, moods: ["dark", "chilled"] },
54
+ classical: { energy: 0.3, valence: 0.5, acoustic: 0.9, moods: ["epic", "calm"] },
55
+ acoustic: { energy: 0.3, valence: 0.55, acoustic: 0.9, moods: ["calm", "romantic"] },
56
+ folk: { energy: 0.4, valence: 0.55, acoustic: 0.8, moods: ["calm", "romantic"] },
57
+ jazz: { energy: 0.45, valence: 0.55, acoustic: 0.6, moods: ["chilled", "sexy"] },
58
+ "nu jazz": { energy: 0.5, valence: 0.55, acoustic: 0.4, moods: ["chilled"] },
59
+ "singer-songwriter": { energy: 0.4, valence: 0.5, acoustic: 0.7, moods: ["calm", "sad"] },
60
+ "post-rock": { energy: 0.5, valence: 0.4, acoustic: 0.4, moods: ["epic", "ethereal"] },
61
+ shoegaze: { energy: 0.55, valence: 0.4, acoustic: 0.3, moods: ["ethereal", "dark"] },
62
+ // ── low energy, low valence → debug-leaning ──────────────────────────
63
+ blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
64
+ slowcore: { energy: 0.25, valence: 0.3, acoustic: 0.5, moods: ["sad", "dark"] },
65
+ sad: { energy: 0.3, valence: 0.2, acoustic: 0.5, moods: ["sad"] },
66
+ melancholy: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
67
+ melancholic: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
68
+ doom: { energy: 0.5, valence: 0.2, acoustic: 0.2, moods: ["dark", "scary"] },
69
+ };
70
+ /** Match a list of raw tags against the affect table and aggregate. */
71
+ export function tagsToVibe(tags) {
72
+ const hits = [];
73
+ for (const raw of tags) {
74
+ const t = raw.toLowerCase();
75
+ for (const key in GENRE_AFFECT) {
76
+ if (t.includes(key)) {
77
+ hits.push(GENRE_AFFECT[key]);
78
+ break; // one row per tag — don't double-count "trip hop" as "hop"
79
+ }
80
+ }
81
+ }
82
+ if (hits.length === 0)
83
+ return null;
84
+ const energy = hits.reduce((s, a) => s + a.energy, 0) / hits.length;
85
+ // Mood words: collect from all hits, dedupe, keep most frequent first.
86
+ const moodCounts = new Map();
87
+ for (const h of hits)
88
+ for (const m of h.moods) {
89
+ moodCounts.set(m, (moodCounts.get(m) ?? 0) + 1);
90
+ }
91
+ const moods = [...moodCounts.entries()]
92
+ .sort((a, b) => b[1] - a[1])
93
+ .slice(0, 4)
94
+ .map(([m]) => m);
95
+ return { moods, energy };
96
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\""
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "Stop": [
15
+ {
16
+ "matcher": "",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/stop.js\""
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@cullumco/cadence",
3
+ "version": "0.1.0",
4
+ "description": "Agents that read the room. Ambient context, cadence dials, and finish-line guardrails for Claude Code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadence": "bin/cadence"
8
+ },
9
+ "homepage": "https://github.com/cullumco/cadence",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/cullumco/cadence.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/cullumco/cadence/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ ".claude-plugin",
22
+ "hooks",
23
+ "skills",
24
+ "dist",
25
+ "bin",
26
+ "README.md",
27
+ "ALPHA.md"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "dev": "tsc --watch",
32
+ "test": "node --test test/cadence.test.js",
33
+ "prepack": "npm run build",
34
+ "prepublishOnly": "node scripts/verify-alpha.mjs --no-install-smoke",
35
+ "release:alpha": "node scripts/release-alpha.mjs",
36
+ "verify:alpha": "node scripts/verify-alpha.mjs",
37
+ "plugin:validate": "claude plugin validate .claude-plugin/plugin.json && claude plugin validate --strict .claude-plugin/marketplace.json",
38
+ "hook": "node dist/hook.js",
39
+ "cli": "node dist/cli.js"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.11.0",
46
+ "typescript": "^5.4.0"
47
+ },
48
+ "keywords": [
49
+ "claude-code",
50
+ "claude",
51
+ "spotify",
52
+ "context",
53
+ "agent",
54
+ "hook",
55
+ "cadence",
56
+ "context-engineering"
57
+ ],
58
+ "license": "MIT",
59
+ "author": "Cullum&Co"
60
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Set or inspect the user's self-reported Cadence state, such as "shipping", "thinking", or "tired but pushing".
3
+ disable-model-invocation: true
4
+ ---
5
+
6
+ # Cadence State
7
+
8
+ Use this skill when the user invokes `/cadence:state`.
9
+
10
+ If `$ARGUMENTS` is non-empty:
11
+ - Run `cadence state "$ARGUMENTS"` with Bash.
12
+ - Then tell the user the state is set and will expire after four hours.
13
+ - Keep it to one short sentence.
14
+
15
+ If `$ARGUMENTS` is empty:
16
+ - Run `cadence state` with Bash.
17
+ - Tell the user the current state, or that none is set.
18
+
19
+ Do not explain the whole product.
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Show what Cadence is doing right now and give the user a quick way to feel the difference.
3
+ disable-model-invocation: true
4
+ ---
5
+
6
+ # Try Cadence
7
+
8
+ When the user invokes this skill, make Cadence immediately legible.
9
+
10
+ If a `<user_state>` block is present in context:
11
+ - Briefly summarize the visible signals and cadence dials in plain English.
12
+ - Name one concrete way that cadence should change your response style.
13
+ - Give the user one tiny prompt to try next that would make the difference obvious.
14
+
15
+ If no `<user_state>` block is present:
16
+ - Say Cadence does not appear to be injecting context in this session yet.
17
+ - Tell the user to install/enable the plugin or run `cadence test` from the shell.
18
+
19
+ Keep the response short, warm, and practical. Do not explain the whole product.