@cullumco/cadence 0.1.4 → 0.1.6

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,142 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { providerEnabled } from "../config.js";
5
+ import { debug } from "../debug.js";
6
+ /* ─────────────────────────────────────────────────────────────────────────
7
+ * Spotify now-playing — the CROSS-PLATFORM music source (opt-in).
8
+ *
9
+ * macOS already reads Spotify.app / Music.app via AppleScript; this is for the
10
+ * Linux/Windows user (or anyone who'd rather not script the desktop app). It
11
+ * is NOT the deprecated audio-features API — only `currently-playing`, which
12
+ * is still live. Vibe still comes from MusicBrainz downstream, so this returns
13
+ * identity only (track + artist), exactly like the AppleScript path.
14
+ *
15
+ * Opt-in and BYO-credentials — no shared client, no callback server in a
16
+ * background hook. The user registers their own Spotify app and supplies a
17
+ * refresh token + client id (see `cadence spotify`); we refresh the short-lived
18
+ * access token ourselves and cache it so a normal prompt makes ONE request.
19
+ * Fail-silent throughout: any hiccup degrades to "no music," never throws.
20
+ * ───────────────────────────────────────────────────────────────────────── */
21
+ const TOKEN_CACHE = join(homedir(), ".cadence", "spotify-token.json");
22
+ const REFRESH_TIMEOUT_MS = 800;
23
+ const NOWPLAYING_TIMEOUT_MS = 800;
24
+ const TOKEN_SKEW_MS = 60_000; // refresh a minute early so a live token can't expire mid-flight
25
+ /** Pull and validate the Spotify creds out of the provider registry, or null
26
+ * when the user hasn't opted in / the shape is incomplete. Pure + exported. */
27
+ export function readCreds(providers) {
28
+ if (!providerEnabled(providers, "spotify"))
29
+ return null;
30
+ const v = providers["spotify"];
31
+ if (!v || typeof v !== "object")
32
+ return null;
33
+ const o = v;
34
+ const refreshToken = o["refreshToken"];
35
+ const clientId = o["clientId"];
36
+ if (typeof refreshToken !== "string" || typeof clientId !== "string")
37
+ return null;
38
+ if (!refreshToken || !clientId)
39
+ return null;
40
+ const clientSecret = typeof o["clientSecret"] === "string" ? o["clientSecret"] : undefined;
41
+ return { refreshToken, clientId, clientSecret };
42
+ }
43
+ async function loadToken() {
44
+ try {
45
+ const t = JSON.parse(await readFile(TOKEN_CACHE, "utf-8"));
46
+ return typeof t?.accessToken === "string" && typeof t?.expiresAt === "number" ? t : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ async function saveToken(t) {
53
+ try {
54
+ await mkdir(join(homedir(), ".cadence"), { recursive: true });
55
+ await writeFile(TOKEN_CACHE, JSON.stringify(t), "utf-8");
56
+ }
57
+ catch {
58
+ // best-effort; a failed cache just means we refresh again next prompt
59
+ }
60
+ }
61
+ async function refreshAccessToken(creds) {
62
+ const ctrl = new AbortController();
63
+ const timer = setTimeout(() => ctrl.abort(), REFRESH_TIMEOUT_MS);
64
+ try {
65
+ const body = new URLSearchParams({
66
+ grant_type: "refresh_token",
67
+ refresh_token: creds.refreshToken,
68
+ client_id: creds.clientId, // in-body client_id covers PKCE/public apps
69
+ });
70
+ const headers = {
71
+ "Content-Type": "application/x-www-form-urlencoded",
72
+ };
73
+ if (creds.clientSecret) {
74
+ const basic = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString("base64");
75
+ headers["Authorization"] = `Basic ${basic}`;
76
+ }
77
+ const res = await fetch("https://accounts.spotify.com/api/token", {
78
+ method: "POST",
79
+ headers,
80
+ body,
81
+ signal: ctrl.signal,
82
+ });
83
+ if (!res.ok) {
84
+ debug("spotify", `token refresh failed: ${res.status}`);
85
+ return null;
86
+ }
87
+ const data = (await res.json());
88
+ if (!data.access_token)
89
+ return null;
90
+ await saveToken({
91
+ accessToken: data.access_token,
92
+ expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000,
93
+ });
94
+ return data.access_token;
95
+ }
96
+ catch (e) {
97
+ debug("spotify", `token refresh error: ${e instanceof Error ? e.message : String(e)}`);
98
+ return null;
99
+ }
100
+ finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+ async function getAccessToken(creds) {
105
+ const cached = await loadToken();
106
+ if (cached && cached.expiresAt - TOKEN_SKEW_MS > Date.now())
107
+ return cached.accessToken;
108
+ return refreshAccessToken(creds);
109
+ }
110
+ export async function getSpotifyNowPlaying(providers) {
111
+ const creds = readCreds(providers);
112
+ if (!creds)
113
+ return null;
114
+ const token = await getAccessToken(creds);
115
+ if (!token)
116
+ return null;
117
+ const ctrl = new AbortController();
118
+ const timer = setTimeout(() => ctrl.abort(), NOWPLAYING_TIMEOUT_MS);
119
+ try {
120
+ const res = await fetch("https://api.spotify.com/v1/me/player/currently-playing", {
121
+ headers: { Authorization: `Bearer ${token}` },
122
+ signal: ctrl.signal,
123
+ });
124
+ if (res.status === 204 || !res.ok)
125
+ return null; // 204 = nothing playing
126
+ const data = (await res.json());
127
+ if (data.is_playing === false)
128
+ return null;
129
+ const track = data.item?.name;
130
+ const artist = data.item?.artists?.map((a) => a.name).filter(Boolean).join(", ");
131
+ if (!track || !artist)
132
+ return null;
133
+ return { track, artist, player: "Spotify" };
134
+ }
135
+ catch (e) {
136
+ debug("spotify", `now-playing error: ${e instanceof Error ? e.message : String(e)}`);
137
+ return null;
138
+ }
139
+ finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
@@ -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 state "deep work"`)';
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 state "..."` to give it one';
43
+ return 'cadence: live, no signals right now — `cadence report "..."` to give it one';
33
44
  }
34
- return `cadence: live ${seen.join(" · ")} (inputs: cadence state | dials)`;
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() {
@@ -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 ambientRows(a, platform) {
18
+ function environmentRows(a, platform, providers) {
18
19
  if (!a)
19
- return [top("ambient", "— unavailable")];
20
+ return [top("environment", "— unavailable")];
20
21
  const mac = platform === "darwin";
21
22
  const macNote = "— macOS only";
22
- const lines = [" ambient"];
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
@@ -45,9 +46,11 @@ function ambientRows(a, platform) {
45
46
  : row("displays", String(a.displays), "(hidden: only shows >1)"));
46
47
  lines.push(!mac
47
48
  ? row("wifi", macNote)
48
- : a.network
49
- ? row("wifi", JSON.stringify(a.network))
50
- : row("wifi", "— unavailable"));
49
+ : !providerEnabled(providers, "wifi")
50
+ ? row("wifi", "— off (run: cadence enable wifi)")
51
+ : a.network
52
+ ? row("wifi", JSON.stringify(a.network))
53
+ : row("wifi", "— unavailable (macOS may require Location Services)"));
51
54
  lines.push(a.uptimeHours == null
52
55
  ? row("uptime", "— unavailable")
53
56
  : a.uptimeHours >= 12
@@ -65,17 +68,21 @@ function ambientRows(a, platform) {
65
68
  : row("focus", "off", "(hidden: only shows on)"));
66
69
  return lines;
67
70
  }
68
- function musicRows(m) {
71
+ function musicRows(m, providers) {
72
+ const spotify = providerEnabled(providers, "spotify")
73
+ ? row("source", "macOS apps + Spotify (cross-platform, linked)")
74
+ : row("source", "macOS apps only", "(cross-platform: cadence spotify)");
69
75
  if (!m?.track)
70
- return [top("music", "— nothing playing")];
76
+ return [top("music", "— nothing playing"), spotify];
71
77
  const lines = [" music"];
72
78
  lines.push(row("track", `${JSON.stringify(m.track)}${m.artist ? ` — ${m.artist}` : ""}${m.player ? ` (${m.player})` : ""}`));
73
79
  lines.push(m.vibe ? row("vibe", m.vibe) : row("vibe", "— no tags yet (looked up once per artist)"));
80
+ lines.push(spotify);
74
81
  return lines;
75
82
  }
76
83
  function reportRow(r, now) {
77
84
  if (!r)
78
- return top("self_report", '— none set (run: cadence state "...")');
85
+ return top("self_report", '— none set (run: cadence report "...")');
79
86
  const text = r.text.length > 44 ? `${r.text.slice(0, 43)}…` : r.text;
80
87
  return top("self_report", `${JSON.stringify(text)} (${ttlLeft(r.setAt, now)})`);
81
88
  }
@@ -92,12 +99,39 @@ function gitRow(g) {
92
99
  ].filter(Boolean);
93
100
  return top("git", parts.join(", "));
94
101
  }
102
+ function intentRow() {
103
+ return top("intent", "— reads your prompt (the hook infers ship/think/debug per-prompt)");
104
+ }
105
+ function tempoRow(providers) {
106
+ return providerEnabled(providers, "typingTempo")
107
+ ? top("typing tempo", "on (opt-in) — rapid vs. considered prompt rhythm → pace")
108
+ : top("typing tempo", "— off (opt-in: cadence enable typingTempo)");
109
+ }
110
+ function optInFlavorRows(providers, environment) {
111
+ const sign = providerSetting(providers, "horoscope");
112
+ return [
113
+ " opt-in flavor",
114
+ row("focused app", providerEnabled(providers, "focusedApp")
115
+ ? environment?.focusedApp ?? "on — nothing non-terminal in front"
116
+ : "— off (cadence enable focusedApp)"),
117
+ row("moon", providerEnabled(providers, "moon")
118
+ ? "on — phase shows in the block"
119
+ : "— off (cadence enable moon)"),
120
+ row("horoscope", typeof sign === "string"
121
+ ? `on (${sign}) — daily text shows in the block`
122
+ : "— off (cadence enable horoscope <sign>)"),
123
+ ];
124
+ }
95
125
  export function renderSignalsTable(raw) {
126
+ const providers = raw.providers ?? {};
96
127
  return [
97
- ...ambientRows(raw.ambient, raw.platform),
98
- ...musicRows(raw.music),
128
+ ...environmentRows(raw.environment, raw.platform, raw.providers ?? {}),
129
+ ...musicRows(raw.music, providers),
99
130
  reportRow(raw.report, raw.now),
131
+ intentRow(),
100
132
  gitRow(raw.git),
101
133
  top("activity", "— session-only (the hook injects it per-prompt)"),
134
+ tempoRow(providers),
135
+ ...optInFlavorRows(providers, raw.environment),
102
136
  ].join("\n");
103
137
  }
@@ -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 { getAmbientSignal } from "./providers/ambient.js";
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, ambient, git] = await Promise.allSettled([
24
+ const [music, report, environment, git] = await Promise.allSettled([
24
25
  getMusicSignal(),
25
26
  getSelfReportSignal(),
26
- getAmbientSignal(new Date()),
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 (ambient.status === "fulfilled" && ambient.value)
35
- signals.push(ambient.value);
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 → ship ───────────────────────────────────────────────
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 → ship-or-think depending on energy ─────────────────
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 → think ──────────────────────────────────────
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 → debug-leaning ──────────────────────────
115
+ // ── low energy, low valence → pace low; dark moods are render-only ───
116
+ // (valence currently moves NO dial — see BACKLOG "Valence 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 energy = hits.reduce((s, a) => s + a.energy, 0) / hits.length;
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.4",
3
+ "version": "0.1.6",
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://cullumco.github.io/cadence/",
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.