@cullumco/cadence 0.1.4 → 0.1.5
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +112 -64
- package/dist/cadence.js +58 -17
- package/dist/cli.js +216 -23
- package/dist/config.js +76 -0
- package/dist/hook.js +37 -18
- package/dist/inject.js +21 -3
- package/dist/posttool.js +57 -4
- package/dist/providers/activity.js +45 -4
- package/dist/providers/{ambient.js → environment.js} +25 -5
- package/dist/providers/esoteric.js +77 -0
- package/dist/providers/git.js +4 -3
- package/dist/providers/intent.js +37 -0
- package/dist/providers/moon.js +51 -0
- package/dist/providers/music.js +15 -3
- package/dist/providers/selfreport.js +6 -1
- package/dist/providers/spotify.js +142 -0
- package/dist/session-start.js +38 -8
- package/dist/signals-view.js +40 -8
- package/dist/spotify-auth.js +136 -0
- package/dist/stop.js +9 -6
- package/dist/types.js +1 -0
- package/dist/vibe.js +10 -6
- package/package.json +2 -2
- package/skills/pause/SKILL.md +16 -0
- package/skills/resume/SKILL.md +12 -0
- package/skills/setup/SKILL.md +43 -0
- package/skills/state/SKILL.md +3 -3
package/dist/session-start.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { getMusicSignal } from "./providers/music.js";
|
|
3
|
-
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
3
|
+
import { getSelfReportSignal, STALE_AFTER_MS, REFRESH_SOON_MS } from "./providers/selfreport.js";
|
|
4
4
|
import { loadOverrides } from "./cadence.js";
|
|
5
|
+
import { isPaused } from "./config.js";
|
|
5
6
|
import { debug } from "./debug.js";
|
|
6
7
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
7
8
|
* Claude Code SessionStart adapter: one short, human-facing line when a
|
|
@@ -16,24 +17,50 @@ import { debug } from "./debug.js";
|
|
|
16
17
|
const BUDGET_MS = 700;
|
|
17
18
|
// The voice of the product's first impression. Return null to stay silent.
|
|
18
19
|
// Default policy: always one line on startup — say what's seen when there
|
|
19
|
-
// are signals, point at the inputs when there's nothing yet
|
|
20
|
+
// are signals, point at the inputs when there's nothing yet, and invite a
|
|
21
|
+
// refresh when the self-report is about to go stale ("inquire about updating").
|
|
20
22
|
export function composeHint(info) {
|
|
23
|
+
// Paused: the model-facing hooks are silent, but this line is for the USER —
|
|
24
|
+
// say so once per session so "off" never reads as "broken".
|
|
25
|
+
if (info.paused) {
|
|
26
|
+
return "cadence: paused — prompts go through untouched (`cadence resume` or /cadence:resume to turn it back on)";
|
|
27
|
+
}
|
|
21
28
|
if (info.firstRun) {
|
|
22
|
-
return 'cadence: on, but it hasn\'t heard from you — try `cadence start` (or just `cadence
|
|
29
|
+
return 'cadence: on, but it hasn\'t heard from you — try `cadence start` (or just `cadence report "deep work"`)';
|
|
23
30
|
}
|
|
31
|
+
const expiringSoon = info.selfReport != null &&
|
|
32
|
+
info.selfReportRemainingMs != null &&
|
|
33
|
+
info.selfReportRemainingMs <= REFRESH_SOON_MS;
|
|
24
34
|
const seen = [];
|
|
25
|
-
if (info.selfReport)
|
|
26
|
-
seen.push(`state "${info.selfReport}"`);
|
|
35
|
+
if (info.selfReport) {
|
|
36
|
+
seen.push(`state "${info.selfReport}"${expiringSoon ? " (expiring)" : ""}`);
|
|
37
|
+
}
|
|
27
38
|
if (info.nowPlaying)
|
|
28
39
|
seen.push(`${info.nowPlaying.player}: ${info.nowPlaying.artist}`);
|
|
29
40
|
if (info.pinned.length)
|
|
30
41
|
seen.push(`pinned ${info.pinned.join(", ")}`);
|
|
31
42
|
if (seen.length === 0) {
|
|
32
|
-
return 'cadence: live, no signals right now — `cadence
|
|
43
|
+
return 'cadence: live, no signals right now — `cadence report "..."` to give it one';
|
|
33
44
|
}
|
|
34
|
-
|
|
45
|
+
// When state is about to expire, the inputs hint becomes an explicit nudge to
|
|
46
|
+
// re-declare — so a long session keeps the cadence honest as the room shifts.
|
|
47
|
+
const tail = expiringSoon
|
|
48
|
+
? "still in this cadence? `cadence report \"...\"` to refresh"
|
|
49
|
+
: "inputs: cadence report | dials";
|
|
50
|
+
return `cadence: live — ${seen.join(" · ")} (${tail})`;
|
|
35
51
|
}
|
|
36
52
|
async function collectInfo() {
|
|
53
|
+
// Paused short-circuits everything — don't even probe for music.
|
|
54
|
+
if (await isPaused()) {
|
|
55
|
+
return {
|
|
56
|
+
selfReport: null,
|
|
57
|
+
selfReportRemainingMs: null,
|
|
58
|
+
pinned: [],
|
|
59
|
+
nowPlaying: null,
|
|
60
|
+
firstRun: false,
|
|
61
|
+
paused: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
37
64
|
// Race music against the budget — MusicBrainz on a brand-new artist can
|
|
38
65
|
// be slow, and a session greeting must never delay the session.
|
|
39
66
|
const [report, overrides, music] = await Promise.all([
|
|
@@ -41,15 +68,18 @@ async function collectInfo() {
|
|
|
41
68
|
loadOverrides(),
|
|
42
69
|
Promise.race([
|
|
43
70
|
getMusicSignal().catch(() => null),
|
|
44
|
-
new Promise((resolve) => setTimeout(() => resolve(null), BUDGET_MS)),
|
|
71
|
+
new Promise((resolve) => setTimeout(() => resolve(null), BUDGET_MS).unref()),
|
|
45
72
|
]),
|
|
46
73
|
]);
|
|
47
74
|
const pinned = Object.keys(overrides);
|
|
75
|
+
const remaining = report ? Math.max(0, STALE_AFTER_MS - (Date.now() - report.setAt)) : null;
|
|
48
76
|
return {
|
|
49
77
|
selfReport: report?.text ?? null,
|
|
78
|
+
selfReportRemainingMs: remaining,
|
|
50
79
|
pinned,
|
|
51
80
|
nowPlaying: music?.artist ? { artist: music.artist, player: music.player ?? "music" } : null,
|
|
52
81
|
firstRun: !report && pinned.length === 0,
|
|
82
|
+
paused: false,
|
|
53
83
|
};
|
|
54
84
|
}
|
|
55
85
|
async function main() {
|
package/dist/signals-view.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { STALE_AFTER_MS } from "./providers/selfreport.js";
|
|
2
|
+
import { providerEnabled, providerSetting } from "./config.js";
|
|
2
3
|
const LABEL_W = 12; // sub-row label column
|
|
3
4
|
const VALUE_W = 18; // value column, before a "(hidden: …)" note
|
|
4
5
|
function row(label, value, note) {
|
|
@@ -14,12 +15,12 @@ function ttlLeft(setAt, now) {
|
|
|
14
15
|
const m = Math.floor((rem % 3_600_000) / 60_000);
|
|
15
16
|
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m left` : `${m}m left`;
|
|
16
17
|
}
|
|
17
|
-
function
|
|
18
|
+
function environmentRows(a, platform) {
|
|
18
19
|
if (!a)
|
|
19
|
-
return [top("
|
|
20
|
+
return [top("environment", "— unavailable")];
|
|
20
21
|
const mac = platform === "darwin";
|
|
21
22
|
const macNote = "— macOS only";
|
|
22
|
-
const lines = ["
|
|
23
|
+
const lines = [" environment"];
|
|
23
24
|
lines.push(row("time", `${a.partOfDay} (${a.dayOfWeek})`));
|
|
24
25
|
lines.push(row("weather", a.weather ?? "— off (run: cadence set-location <lat> <lon>)"));
|
|
25
26
|
lines.push(!mac
|
|
@@ -65,17 +66,21 @@ function ambientRows(a, platform) {
|
|
|
65
66
|
: row("focus", "off", "(hidden: only shows on)"));
|
|
66
67
|
return lines;
|
|
67
68
|
}
|
|
68
|
-
function musicRows(m) {
|
|
69
|
+
function musicRows(m, providers) {
|
|
70
|
+
const spotify = providerEnabled(providers, "spotify")
|
|
71
|
+
? row("source", "macOS apps + Spotify (cross-platform, linked)")
|
|
72
|
+
: row("source", "macOS apps only", "(cross-platform: cadence spotify)");
|
|
69
73
|
if (!m?.track)
|
|
70
|
-
return [top("music", "— nothing playing")];
|
|
74
|
+
return [top("music", "— nothing playing"), spotify];
|
|
71
75
|
const lines = [" music"];
|
|
72
76
|
lines.push(row("track", `${JSON.stringify(m.track)}${m.artist ? ` — ${m.artist}` : ""}${m.player ? ` (${m.player})` : ""}`));
|
|
73
77
|
lines.push(m.vibe ? row("vibe", m.vibe) : row("vibe", "— no tags yet (looked up once per artist)"));
|
|
78
|
+
lines.push(spotify);
|
|
74
79
|
return lines;
|
|
75
80
|
}
|
|
76
81
|
function reportRow(r, now) {
|
|
77
82
|
if (!r)
|
|
78
|
-
return top("self_report", '— none set (run: cadence
|
|
83
|
+
return top("self_report", '— none set (run: cadence report "...")');
|
|
79
84
|
const text = r.text.length > 44 ? `${r.text.slice(0, 43)}…` : r.text;
|
|
80
85
|
return top("self_report", `${JSON.stringify(text)} (${ttlLeft(r.setAt, now)})`);
|
|
81
86
|
}
|
|
@@ -92,12 +97,39 @@ function gitRow(g) {
|
|
|
92
97
|
].filter(Boolean);
|
|
93
98
|
return top("git", parts.join(", "));
|
|
94
99
|
}
|
|
100
|
+
function intentRow() {
|
|
101
|
+
return top("intent", "— reads your prompt (the hook infers ship/think/debug per-prompt)");
|
|
102
|
+
}
|
|
103
|
+
function tempoRow(providers) {
|
|
104
|
+
return providerEnabled(providers, "typingTempo")
|
|
105
|
+
? top("typing tempo", "on (opt-in) — rapid vs. considered prompt rhythm → pace")
|
|
106
|
+
: top("typing tempo", "— off (opt-in: cadence enable typingTempo)");
|
|
107
|
+
}
|
|
108
|
+
function optInFlavorRows(providers, environment) {
|
|
109
|
+
const sign = providerSetting(providers, "horoscope");
|
|
110
|
+
return [
|
|
111
|
+
" opt-in flavor",
|
|
112
|
+
row("focused app", providerEnabled(providers, "focusedApp")
|
|
113
|
+
? environment?.focusedApp ?? "on — nothing non-terminal in front"
|
|
114
|
+
: "— off (cadence enable focusedApp)"),
|
|
115
|
+
row("moon", providerEnabled(providers, "moon")
|
|
116
|
+
? "on — phase shows in the block"
|
|
117
|
+
: "— off (cadence enable moon)"),
|
|
118
|
+
row("horoscope", typeof sign === "string"
|
|
119
|
+
? `on (${sign}) — daily text shows in the block`
|
|
120
|
+
: "— off (cadence enable horoscope <sign>)"),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
95
123
|
export function renderSignalsTable(raw) {
|
|
124
|
+
const providers = raw.providers ?? {};
|
|
96
125
|
return [
|
|
97
|
-
...
|
|
98
|
-
...musicRows(raw.music),
|
|
126
|
+
...environmentRows(raw.environment, raw.platform),
|
|
127
|
+
...musicRows(raw.music, providers),
|
|
99
128
|
reportRow(raw.report, raw.now),
|
|
129
|
+
intentRow(),
|
|
100
130
|
gitRow(raw.git),
|
|
101
131
|
top("activity", "— session-only (the hook injects it per-prompt)"),
|
|
132
|
+
tempoRow(providers),
|
|
133
|
+
...optInFlavorRows(providers, raw.environment),
|
|
102
134
|
].join("\n");
|
|
103
135
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Spotify connect — Authorization Code + PKCE, run from the INTERACTIVE CLI.
|
|
6
|
+
*
|
|
7
|
+
* A distributed CLI can't keep a client secret, so we use PKCE (public client,
|
|
8
|
+
* no secret). The flow: spin up a one-shot loopback server, open the browser to
|
|
9
|
+
* Spotify's consent page, catch the redirect with the auth code, exchange it for
|
|
10
|
+
* a refresh token, and hand that back to the CLI to store in `providers.spotify`
|
|
11
|
+
* — the exact shape the fail-silent hook-side provider already reads.
|
|
12
|
+
*
|
|
13
|
+
* This NEVER runs in the hook (no browser, no server in a 1.5s budget). The hook
|
|
14
|
+
* only ever reads the cached refresh token. Keep it that way.
|
|
15
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
16
|
+
// Must be registered verbatim in the Spotify app's redirect URIs. Spotify
|
|
17
|
+
// requires the explicit loopback IP (not "localhost") for native/CLI apps.
|
|
18
|
+
export const REDIRECT_PORT = 8888;
|
|
19
|
+
export const REDIRECT_URI = `http://127.0.0.1:${REDIRECT_PORT}/callback`;
|
|
20
|
+
export const SCOPE = "user-read-currently-playing";
|
|
21
|
+
const AUTH_TIMEOUT_MS = 120_000;
|
|
22
|
+
/** PKCE pair: a high-entropy verifier and its S256 challenge. Pure. */
|
|
23
|
+
export function generatePkce() {
|
|
24
|
+
const verifier = randomBytes(32).toString("base64url"); // 43 chars, within 43–128
|
|
25
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
26
|
+
return { verifier, challenge };
|
|
27
|
+
}
|
|
28
|
+
/** Build the Spotify consent URL. Pure + exported so the params are testable. */
|
|
29
|
+
export function buildAuthorizeUrl(opts) {
|
|
30
|
+
const q = new URLSearchParams({
|
|
31
|
+
response_type: "code",
|
|
32
|
+
client_id: opts.clientId,
|
|
33
|
+
redirect_uri: REDIRECT_URI,
|
|
34
|
+
scope: SCOPE,
|
|
35
|
+
code_challenge_method: "S256",
|
|
36
|
+
code_challenge: opts.challenge,
|
|
37
|
+
state: opts.state,
|
|
38
|
+
});
|
|
39
|
+
return `https://accounts.spotify.com/authorize?${q.toString()}`;
|
|
40
|
+
}
|
|
41
|
+
/** Pull the refresh token out of Spotify's token response, or null. Pure. */
|
|
42
|
+
export function parseTokenResponse(json) {
|
|
43
|
+
const data = json;
|
|
44
|
+
return typeof data?.refresh_token === "string" && data.refresh_token ? data.refresh_token : null;
|
|
45
|
+
}
|
|
46
|
+
// Best-effort browser open; if it fails we've already printed the URL to paste.
|
|
47
|
+
function openBrowser(url) {
|
|
48
|
+
const cmd = process.platform === "darwin"
|
|
49
|
+
? "open"
|
|
50
|
+
: process.platform === "win32"
|
|
51
|
+
? "start \"\""
|
|
52
|
+
: "xdg-open";
|
|
53
|
+
const child = exec(`${cmd} "${url}"`, () => { });
|
|
54
|
+
child.on("error", () => { });
|
|
55
|
+
}
|
|
56
|
+
// Wait for Spotify to redirect back with ?code=&state=, validating state.
|
|
57
|
+
function waitForCode(expectedState) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const server = createServer((req, res) => {
|
|
60
|
+
const url = new URL(req.url ?? "/", REDIRECT_URI);
|
|
61
|
+
if (url.pathname !== "/callback") {
|
|
62
|
+
res.writeHead(404).end();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const code = url.searchParams.get("code");
|
|
66
|
+
const state = url.searchParams.get("state");
|
|
67
|
+
const err = url.searchParams.get("error");
|
|
68
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
69
|
+
const done = (msg) => res.end(`<!doctype html><meta charset=utf-8><body style="font:16px system-ui;padding:3rem">${msg} — you can close this tab.</body>`);
|
|
70
|
+
if (err) {
|
|
71
|
+
done(`Spotify returned "${err}"`);
|
|
72
|
+
cleanup();
|
|
73
|
+
reject(new Error(`authorization denied: ${err}`));
|
|
74
|
+
}
|
|
75
|
+
else if (!code || state !== expectedState) {
|
|
76
|
+
done("Something didn't line up");
|
|
77
|
+
cleanup();
|
|
78
|
+
reject(new Error("missing code or state mismatch"));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
done("Spotify linked ✓");
|
|
82
|
+
cleanup();
|
|
83
|
+
resolve(code);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
cleanup();
|
|
88
|
+
reject(new Error("timed out waiting for Spotify authorization"));
|
|
89
|
+
}, AUTH_TIMEOUT_MS);
|
|
90
|
+
function cleanup() {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
server.close();
|
|
93
|
+
}
|
|
94
|
+
server.on("error", (e) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
reject(e.code === "EADDRINUSE"
|
|
97
|
+
? new Error(`port ${REDIRECT_PORT} is in use — close whatever's on it and retry`)
|
|
98
|
+
: e);
|
|
99
|
+
});
|
|
100
|
+
server.listen(REDIRECT_PORT, "127.0.0.1");
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function exchangeCode(clientId, code, verifier) {
|
|
104
|
+
const res = await fetch("https://accounts.spotify.com/api/token", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
107
|
+
body: new URLSearchParams({
|
|
108
|
+
grant_type: "authorization_code",
|
|
109
|
+
code,
|
|
110
|
+
redirect_uri: REDIRECT_URI,
|
|
111
|
+
client_id: clientId,
|
|
112
|
+
code_verifier: verifier,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
|
|
117
|
+
const refreshToken = parseTokenResponse(await res.json());
|
|
118
|
+
if (!refreshToken)
|
|
119
|
+
throw new Error("Spotify did not return a refresh token");
|
|
120
|
+
return refreshToken;
|
|
121
|
+
}
|
|
122
|
+
/** Run the full browser flow. Resolves with a refresh token to store, or
|
|
123
|
+
* throws with a human-readable reason. Logs progress via the passed printer so
|
|
124
|
+
* the CLI owns all stdout. */
|
|
125
|
+
export async function connectSpotify(clientId, log) {
|
|
126
|
+
const { verifier, challenge } = generatePkce();
|
|
127
|
+
const state = randomBytes(16).toString("hex");
|
|
128
|
+
const url = buildAuthorizeUrl({ clientId, challenge, state });
|
|
129
|
+
log(" Opening Spotify in your browser to authorize…");
|
|
130
|
+
log(" If it doesn't open, paste this:\n");
|
|
131
|
+
log(` ${url}\n`);
|
|
132
|
+
openBrowser(url);
|
|
133
|
+
const code = await waitForCode(state);
|
|
134
|
+
log(" Got it — exchanging for a refresh token…");
|
|
135
|
+
return exchangeCode(clientId, code, verifier);
|
|
136
|
+
}
|
package/dist/stop.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { getMusicSignal } from "./providers/music.js";
|
|
4
4
|
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
5
|
-
import {
|
|
5
|
+
import { getEnvironmentSignal } from "./providers/environment.js";
|
|
6
6
|
import { getGitSignal } from "./providers/git.js";
|
|
7
7
|
import { deriveCadence, loadOverrides, applyOverrides } from "./cadence.js";
|
|
8
|
+
import { isPaused } from "./config.js";
|
|
8
9
|
const TOTAL_BUDGET_MS = 1500;
|
|
9
10
|
async function readStdin() {
|
|
10
11
|
if (process.stdin.isTTY)
|
|
@@ -20,10 +21,10 @@ async function readStdin() {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
async function collectSignals(cwd) {
|
|
23
|
-
const [music, report,
|
|
24
|
+
const [music, report, environment, git] = await Promise.allSettled([
|
|
24
25
|
getMusicSignal(),
|
|
25
26
|
getSelfReportSignal(),
|
|
26
|
-
|
|
27
|
+
getEnvironmentSignal(new Date()),
|
|
27
28
|
getGitSignal(cwd),
|
|
28
29
|
]);
|
|
29
30
|
const signals = [];
|
|
@@ -31,8 +32,8 @@ async function collectSignals(cwd) {
|
|
|
31
32
|
signals.push(music.value);
|
|
32
33
|
if (report.status === "fulfilled" && report.value)
|
|
33
34
|
signals.push(report.value);
|
|
34
|
-
if (
|
|
35
|
-
signals.push(
|
|
35
|
+
if (environment.status === "fulfilled" && environment.value)
|
|
36
|
+
signals.push(environment.value);
|
|
36
37
|
if (git.status === "fulfilled" && git.value)
|
|
37
38
|
signals.push(git.value);
|
|
38
39
|
return signals;
|
|
@@ -75,12 +76,14 @@ export function decideStop(input, signals, cadence, pinned) {
|
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
async function main() {
|
|
79
|
+
if (await isPaused())
|
|
80
|
+
return; // user asked for silence — never block while paused
|
|
78
81
|
const input = await readStdin();
|
|
79
82
|
const projectDir = input.cwd ?? process.cwd();
|
|
80
83
|
const [signals, overrides] = await Promise.all([
|
|
81
84
|
Promise.race([
|
|
82
85
|
collectSignals(projectDir),
|
|
83
|
-
new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS)),
|
|
86
|
+
new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS).unref()),
|
|
84
87
|
]),
|
|
85
88
|
loadOverrides(),
|
|
86
89
|
]);
|
package/dist/types.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The four embodied dimensions, each a provider emitting one Signal:
|
|
10
10
|
* - MusicSignal → what's playing (music)
|
|
11
11
|
* - SelfReportSignal → what you told us (mood, ground truth)
|
|
12
|
+
* - IntentSignal → what your prompt implies (mood, inferred from words)
|
|
12
13
|
* - ActivitySignal → your motor/typing tempo (mood, inferred)
|
|
13
14
|
* - GitSignal → your work state (context)
|
|
14
15
|
* - PlaceSignal → where & in what setting (place)
|
package/dist/vibe.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* (energy: death-metal high → Bach low; valence: euphoric high → depressed low).
|
|
21
21
|
* Order doesn't matter — every matching row is averaged. */
|
|
22
22
|
const GENRE_AFFECT = {
|
|
23
|
-
// ── high energy →
|
|
23
|
+
// ── high energy → pace high ──────────────────────────────────────────
|
|
24
24
|
punk: { energy: 0.9, valence: 0.6, acoustic: 0.05, moods: ["aggressive", "energetic"] },
|
|
25
25
|
metal: { energy: 0.95, valence: 0.4, acoustic: 0.02, moods: ["aggressive", "dark"] },
|
|
26
26
|
hardcore: { energy: 0.95, valence: 0.45, acoustic: 0.02, moods: ["aggressive", "energetic"] },
|
|
@@ -66,7 +66,7 @@ const GENRE_AFFECT = {
|
|
|
66
66
|
ska: { energy: 0.8, valence: 0.75, acoustic: 0.15, moods: ["happy", "energetic"] },
|
|
67
67
|
surf: { energy: 0.7, valence: 0.7, acoustic: 0.2, moods: ["happy", "energetic"] },
|
|
68
68
|
"math rock": { energy: 0.75, valence: 0.55, acoustic: 0.2, moods: ["energetic"] },
|
|
69
|
-
// ── mid / groovy →
|
|
69
|
+
// ── mid / groovy → pace dead zone (0.4 < energy < 0.7 = no nudge) ────
|
|
70
70
|
"r&b": { energy: 0.55, valence: 0.6, acoustic: 0.2, moods: ["sexy", "chilled"] },
|
|
71
71
|
soul: { energy: 0.55, valence: 0.65, acoustic: 0.25, moods: ["romantic", "uplifting"] },
|
|
72
72
|
reggae: { energy: 0.6, valence: 0.75, acoustic: 0.2, moods: ["chilled", "happy"] },
|
|
@@ -86,7 +86,7 @@ const GENRE_AFFECT = {
|
|
|
86
86
|
"big band": { energy: 0.65, valence: 0.7, acoustic: 0.6, moods: ["happy", "epic"] },
|
|
87
87
|
samba: { energy: 0.7, valence: 0.8, acoustic: 0.5, moods: ["happy"] },
|
|
88
88
|
flamenco: { energy: 0.6, valence: 0.5, acoustic: 0.85, moods: ["romantic", "epic"] },
|
|
89
|
-
// ── low energy, organic →
|
|
89
|
+
// ── low energy, organic → pace low, tone warm ────────────────────────
|
|
90
90
|
ambient: { energy: 0.2, valence: 0.5, acoustic: 0.6, moods: ["ethereal", "calm"] },
|
|
91
91
|
"lo-fi": { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
|
|
92
92
|
lofi: { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
|
|
@@ -112,7 +112,8 @@ const GENRE_AFFECT = {
|
|
|
112
112
|
soundtrack: { energy: 0.45, valence: 0.45, acoustic: 0.6, moods: ["epic"] },
|
|
113
113
|
opera: { energy: 0.5, valence: 0.45, acoustic: 0.9, moods: ["epic"] },
|
|
114
114
|
choral: { energy: 0.3, valence: 0.5, acoustic: 0.95, moods: ["ethereal", "epic"] },
|
|
115
|
-
// ── low energy, low valence →
|
|
115
|
+
// ── low energy, low valence → pace low; dark moods are render-only ───
|
|
116
|
+
// (valence currently moves NO dial — see BACKLOG "energyToMode boundary")
|
|
116
117
|
blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
117
118
|
goth: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
118
119
|
gothic: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
@@ -143,7 +144,10 @@ export function tagsToVibe(tags) {
|
|
|
143
144
|
}
|
|
144
145
|
if (hits.length === 0)
|
|
145
146
|
return null;
|
|
146
|
-
const
|
|
147
|
+
const avg = (pick) => hits.reduce((s, a) => s + pick(a), 0) / hits.length;
|
|
148
|
+
const energy = avg((a) => a.energy);
|
|
149
|
+
const valence = avg((a) => a.valence);
|
|
150
|
+
const acoustic = avg((a) => a.acoustic);
|
|
147
151
|
// Mood words: collect from all hits, dedupe, keep most frequent first.
|
|
148
152
|
const moodCounts = new Map();
|
|
149
153
|
for (const h of hits)
|
|
@@ -154,5 +158,5 @@ export function tagsToVibe(tags) {
|
|
|
154
158
|
.sort((a, b) => b[1] - a[1])
|
|
155
159
|
.slice(0, 4)
|
|
156
160
|
.map(([m]) => m);
|
|
157
|
-
return { moods, energy };
|
|
161
|
+
return { moods, energy, valence, acoustic };
|
|
158
162
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cullumco/cadence",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Agents that read the room. Ambient context, cadence dials, and finish-line guardrails for Claude Code. macOS-only alpha.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cadence": "bin/cadence"
|
|
8
8
|
},
|
|
9
|
-
"homepage": "https://
|
|
9
|
+
"homepage": "https://cadence.cullum.co/",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "git+https://github.com/cullumco/cadence.git"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Pause Cadence — silence all hooks instantly; prompts go through untouched until resumed.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pause Cadence
|
|
7
|
+
|
|
8
|
+
Run `cadence pause` with Bash.
|
|
9
|
+
|
|
10
|
+
Then confirm in one sentence: Cadence is paused, prompts go through untouched,
|
|
11
|
+
and `/cadence:resume` turns it back on. Their state (self-report, pins,
|
|
12
|
+
opt-ins) is preserved exactly as-is.
|
|
13
|
+
|
|
14
|
+
Note: the current session may already have `<user_state>` blocks in context
|
|
15
|
+
from earlier prompts — ignore them from here on; the user has asked for
|
|
16
|
+
silence.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Resume Cadence — start reading the room again after a pause.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resume Cadence
|
|
7
|
+
|
|
8
|
+
Run `cadence resume` with Bash, then `cadence test`.
|
|
9
|
+
|
|
10
|
+
Confirm in one sentence that Cadence is live again, and summarize what it
|
|
11
|
+
currently sees from the `cadence test` output (or note that no signals are
|
|
12
|
+
present yet). Keep it to two sentences total.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Conversational Cadence setup — shape how the agent reads your prompts, choose which signals to share, all through a short conversation.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cadence Setup
|
|
7
|
+
|
|
8
|
+
Walk the user through shaping Cadence conversationally. You orchestrate; the
|
|
9
|
+
`cadence` CLI is the source of truth — run commands with Bash, never edit
|
|
10
|
+
`~/.cadence/` files directly.
|
|
11
|
+
|
|
12
|
+
Have a short conversation, not a form. Adapt to their answers; skip anything
|
|
13
|
+
they don't care about. The arc:
|
|
14
|
+
|
|
15
|
+
1. **State** — ask how they're working right now (shipping? thinking through
|
|
16
|
+
something? debugging? just vibing?). Phrase it naturally. Turn their answer
|
|
17
|
+
into `cadence report "<their words>"`. Tell them it expires after 2 hours and
|
|
18
|
+
they can refresh with `/cadence:state`.
|
|
19
|
+
|
|
20
|
+
2. **Dials** — ask if there's anything they ALWAYS want, regardless of signals
|
|
21
|
+
(e.g. "always be terse", "never act without asking"). Map to pins:
|
|
22
|
+
`cadence set pace|tone|posture|proactivity <low|medium|high>`. Most people
|
|
23
|
+
should pin nothing — say so. Pins override inference until unset.
|
|
24
|
+
|
|
25
|
+
3. **Opt-in signals** — offer, one line each, that these are off until asked:
|
|
26
|
+
- typing tempo (`cadence enable typingTempo`) — prompt rhythm → pace
|
|
27
|
+
- focused app (`cadence enable focusedApp`, macOS) — frontmost app as flavor
|
|
28
|
+
- weather (`cadence set-location <lat> <lon>`) — needs a location
|
|
29
|
+
- moon / horoscope (`cadence enable moon`, `cadence enable horoscope <sign>`)
|
|
30
|
+
- Spotify off-Mac (`cadence spotify connect <clientId>` — needs a terminal,
|
|
31
|
+
opens a browser; just point them at it, don't run it)
|
|
32
|
+
Enable only what they ask for.
|
|
33
|
+
|
|
34
|
+
4. **Show the result** — run `cadence test` and summarize the injected block in
|
|
35
|
+
one or two plain-English sentences: what the agent now sees, and one way it
|
|
36
|
+
will change responses.
|
|
37
|
+
|
|
38
|
+
Close by mentioning: `/cadence:pause` silences everything instantly,
|
|
39
|
+
`/cadence:resume` brings it back, and `cadence signals` shows every signal and
|
|
40
|
+
why it is or isn't firing.
|
|
41
|
+
|
|
42
|
+
Keep the whole exchange warm and fast — under a minute of their time. Do not
|
|
43
|
+
explain the architecture.
|
package/skills/state/SKILL.md
CHANGED
|
@@ -8,12 +8,12 @@ disable-model-invocation: true
|
|
|
8
8
|
Use this skill when the user invokes `/cadence:state`.
|
|
9
9
|
|
|
10
10
|
If `$ARGUMENTS` is non-empty:
|
|
11
|
-
- Run `cadence
|
|
12
|
-
- Then tell the user the state is set and will expire after
|
|
11
|
+
- Run `cadence report "$ARGUMENTS"` with Bash.
|
|
12
|
+
- Then tell the user the state is set and will expire after two hours.
|
|
13
13
|
- Keep it to one short sentence.
|
|
14
14
|
|
|
15
15
|
If `$ARGUMENTS` is empty:
|
|
16
|
-
- Run `cadence
|
|
16
|
+
- Run `cadence report` with Bash.
|
|
17
17
|
- Tell the user the current state, or that none is set.
|
|
18
18
|
|
|
19
19
|
Do not explain the whole product.
|