@cullumco/cadence 0.1.0 → 0.1.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "displayName": "Cadence",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "description": "Ambient context for Claude Code: embodied signals, cadence dials, and finish-line guardrails.",
6
6
  "author": {
7
7
  "name": "Cullum&Co",
package/dist/cli.js CHANGED
@@ -32,7 +32,9 @@ async function cmdClear() {
32
32
  await writeFile(STATE_FILE, "", "utf-8");
33
33
  console.log(" state cleared");
34
34
  }
35
- async function cmdTest() {
35
+ // Collects live signals and renders the exact block the hook would inject,
36
+ // or null when there's nothing to say. Shared by `test` and the bare command.
37
+ async function buildPreview() {
36
38
  const signals = [];
37
39
  const [music, report, ambient, git, overrides] = await Promise.all([
38
40
  getMusicSignal().catch(() => null),
@@ -49,14 +51,20 @@ async function cmdTest() {
49
51
  signals.push(ambient);
50
52
  if (git)
51
53
  signals.push(git);
52
- if (signals.length === 0 && Object.keys(overrides).length === 0) {
53
- console.log(' (no signals — play something, set: cadence state "...", or pin a dial: cadence set pace fast)');
54
- return;
55
- }
54
+ if (signals.length === 0 && Object.keys(overrides).length === 0)
55
+ return null;
56
56
  const state = { signals, capturedAt: Date.now() };
57
57
  const { cadence, pinned } = applyOverrides(deriveCadence(state), overrides);
58
58
  const reframe = buildReframe(cadence);
59
- console.log("\n" + render({ ...state, cadence, pinned, reframe }) + "\n");
59
+ return render({ ...state, cadence, pinned, reframe });
60
+ }
61
+ async function cmdTest() {
62
+ const block = await buildPreview();
63
+ if (!block) {
64
+ console.log(' (no signals — play something, set: cadence state "...", or pin a dial: cadence set pace fast)');
65
+ return;
66
+ }
67
+ console.log("\n" + block + "\n");
60
68
  }
61
69
  const LEVELS = ["low", "medium", "high"];
62
70
  async function cmdSet(args) {
@@ -150,10 +158,114 @@ async function cmdLocation(args) {
150
158
  await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
151
159
  console.log(` location set${nameParts.length ? ` (${nameParts.join(" ")})` : ""} — weather is now on`);
152
160
  }
161
+ // Has the user ever told Cadence anything? (Signals like time-of-day always
162
+ // exist, so "fresh install" is detected by absence of user INPUT, not signals.)
163
+ async function hasUserInput() {
164
+ const [state, config] = await Promise.all([
165
+ readFile(STATE_FILE, "utf-8").catch(() => ""),
166
+ readFile(CONFIG_FILE, "utf-8").catch(() => "{}"),
167
+ ]);
168
+ let cfg = {};
169
+ try {
170
+ cfg = JSON.parse(config);
171
+ }
172
+ catch {
173
+ // unreadable config = no input
174
+ }
175
+ return state.trim().length > 0 || Object.keys(cfg).length > 0;
176
+ }
177
+ const INPUTS_FOOTER = ` where you can input:
178
+ cadence state "..." how you are right now (4h TTL)
179
+ cadence set <dial> <level> pin a dial: ${DIALS.join(", ")}
180
+ cadence set-location <lat> <lon> opt into weather
181
+ cadence start interactive setup
182
+ cadence help everything else`;
183
+ // Bare \`cadence\`: live status + where to input — not a help dump.
184
+ async function cmdRoot() {
185
+ if (!(await hasUserInput())) {
186
+ console.log("\n cadence — agents that read the room");
187
+ console.log(" It hasn't heard from you yet. Fastest start:\n");
188
+ console.log(' cadence start guided setup (~30s)');
189
+ console.log(' cadence state "ship mode" or just say how you are\n');
190
+ return;
191
+ }
192
+ const block = await buildPreview();
193
+ if (block) {
194
+ console.log("\n" + block + "\n");
195
+ }
196
+ else {
197
+ console.log("\n (no signals right now)\n");
198
+ }
199
+ console.log(INPUTS_FOOTER + "\n");
200
+ }
201
+ // Guided first run: three prompts, every one skippable, nothing destructive.
202
+ async function cmdStart() {
203
+ if (!process.stdin.isTTY) {
204
+ console.log(' cadence start is interactive — run it in a terminal, or use: cadence state "..."');
205
+ return;
206
+ }
207
+ const { createInterface } = await import("node:readline/promises");
208
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
209
+ try {
210
+ console.log("\n cadence — agents that read the room");
211
+ console.log(" Three questions. Enter skips any of them; everything can be changed later.\n");
212
+ // 1 ── self-reported state: the highest-leverage input
213
+ const state = (await rl.question(' 1/3 How are you right now? (e.g. "two beers, ship mode")\n > ')).trim();
214
+ if (state) {
215
+ await mkdir(CADENCE_DIR, { recursive: true });
216
+ await writeFile(STATE_FILE, state, "utf-8");
217
+ console.log(' ✓ set — expires after 4h; update anytime: cadence state "..."\n');
218
+ }
219
+ else {
220
+ console.log(' skipped — later: cadence state "..."\n');
221
+ }
222
+ // 2 ── dial pins: overrides, so only offered, never pushed
223
+ console.log(` 2/3 Pin any dials? Pins override inference until unset.`);
224
+ console.log(` dials: ${DIALS.join(", ")} — levels: low|medium|high`);
225
+ for (;;) {
226
+ const ans = (await rl.question(' pin (e.g. "pace high", enter to continue) > ')).trim();
227
+ if (!ans)
228
+ break;
229
+ const [dial, value] = ans.split(/\s+/);
230
+ if (!dial || !value || !DIALS.includes(dial)) {
231
+ console.log(` format: <dial> <level>, dials: ${DIALS.join(", ")}`);
232
+ continue;
233
+ }
234
+ const d = dial;
235
+ const level = resolveDialLevel(d, value);
236
+ if (!level) {
237
+ console.log(` "${value}" isn't valid for ${dial} — use low|medium|high`);
238
+ continue;
239
+ }
240
+ await cmdSet([dial, value]);
241
+ }
242
+ console.log();
243
+ // 3 ── weather: explicitly opt-in, mirrors cmdLocation's no-silent-geo rule
244
+ const loc = (await rl.question(" 3/3 Weather? Give a location, or enter to leave it off.\n lat lon [name] (e.g. 40.71 -74.01 NYC) > ")).trim();
245
+ if (loc) {
246
+ await cmdLocation(loc.split(/\s+/));
247
+ }
248
+ else {
249
+ console.log(" skipped — weather stays off until: cadence set-location <lat> <lon>");
250
+ }
251
+ console.log("\n Done. Here's exactly what the hook injects right now:");
252
+ await cmdTest();
253
+ }
254
+ catch {
255
+ // Ctrl+D / Ctrl+C mid-wizard: every step saves as it goes, so an early
256
+ // exit just means "stop asking" — never an error, never a rollback.
257
+ console.log("\n setup ended early — anything you answered is saved\n");
258
+ }
259
+ finally {
260
+ rl.close();
261
+ }
262
+ }
153
263
  const HELP = `
154
264
  cadence — agents that read the room
155
265
 
156
266
  daily:
267
+ cadence live status + where to input
268
+ cadence start guided setup (state, dials, weather — all skippable)
157
269
  cadence state "..." set self-reported state (e.g. "two beers, ship mode")
158
270
  cadence state print current self-reported state
159
271
  cadence clear clear self-reported state
@@ -172,6 +284,8 @@ const HELP = `
172
284
  async function main() {
173
285
  const [cmd, ...rest] = process.argv.slice(2);
174
286
  switch (cmd) {
287
+ case "start":
288
+ return cmdStart();
175
289
  case "state":
176
290
  return cmdState(rest);
177
291
  case "clear":
@@ -187,6 +301,7 @@ async function main() {
187
301
  case "set-location":
188
302
  return cmdLocation(rest);
189
303
  case undefined:
304
+ return cmdRoot(); // live status + inputs, not the help dump
190
305
  case "help":
191
306
  case "--help":
192
307
  case "-h":
package/dist/debug.js ADDED
@@ -0,0 +1,17 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * CADENCE_DEBUG=1 surfaces swallowed provider errors on stderr.
3
+ *
4
+ * Providers are fail-silent by contract: a broken signal must degrade to
5
+ * "no signal," never break the hook. But fail-silent code can mask a
6
+ * 100%-reproducible bug (see: the AppleScript that never compiled). This
7
+ * is the escape hatch — stderr only, never stdout, because stdout is the
8
+ * hook protocol channel Claude Code parses.
9
+ *
10
+ * CADENCE_DEBUG=1 node bin/cadence test
11
+ * ───────────────────────────────────────────────────────────────────────── */
12
+ const DEBUG = process.env["CADENCE_DEBUG"] === "1" || process.env["CADENCE_DEBUG"] === "true";
13
+ export function debug(scope, msg) {
14
+ if (!DEBUG)
15
+ return;
16
+ process.stderr.write(`[cadence:${scope}] ${msg}\n`);
17
+ }
package/dist/hook.js CHANGED
@@ -6,6 +6,7 @@ import { getGitSignal } from "./providers/git.js";
6
6
  import { getActivitySignal } from "./providers/activity.js";
7
7
  import { deriveCadence, buildReframe, loadOverrides, applyOverrides } from "./cadence.js";
8
8
  import { render } from "./inject.js";
9
+ import { debug } from "./debug.js";
9
10
  const TOTAL_BUDGET_MS = 1500;
10
11
  // Claude Code alpha adapter: collect portable Cadence signals, derive the core
11
12
  // cadence state, then deliver it through UserPromptSubmit.additionalContext.
@@ -53,7 +54,10 @@ async function main() {
53
54
  const [signals, overrides] = await Promise.all([
54
55
  Promise.race([
55
56
  collectSignals(projectDir, prompt),
56
- new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS)),
57
+ new Promise((resolve) => setTimeout(() => {
58
+ debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
59
+ resolve([]);
60
+ }, TOTAL_BUDGET_MS)),
57
61
  ]),
58
62
  loadOverrides(),
59
63
  ]);
@@ -1,8 +1,9 @@
1
- import { exec } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
  import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { tagsToVibe } from "../vibe.js";
6
+ import { debug } from "../debug.js";
6
7
  /* ─────────────────────────────────────────────────────────────────────────
7
8
  * Music = identity + vibe. No Spotify Web API, no auth, no Premium.
8
9
  *
@@ -17,36 +18,66 @@ const CACHE_FILE = join(homedir(), ".cadence", "vibe-cache.json");
17
18
  const MB_TIMEOUT_MS = 1000;
18
19
  const MAX_TAGS = 4;
19
20
  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
21
+ // Spotify first (matches historical priority), then Apple Music.
22
+ const PLAYERS = ["Spotify", "Music"];
23
+ /* The app name MUST be a literal inside the script: AppleScript resolves
24
+ * terms like `player state` against the target app's scripting dictionary
25
+ * at COMPILE time, so `tell application someVariable` is a guaranteed
26
+ * syntax error (-2741). One script per player, built from a template.
27
+ * Exported for the compile-check regression test. */
28
+ export function playerScript(app) {
29
+ return `
30
+ if application "${app}" is running then
31
+ tell application "${app}"
32
+ if player state is playing then
33
+ return (name of current track) & "|||" & (artist of current track)
34
+ end if
35
+ end tell
36
+ end if
37
+ return ""
35
38
  `;
36
- function osascript(script) {
39
+ }
40
+ /* Compiling `tell application "Spotify"` makes macOS locate the app — on a
41
+ * machine where it isn't installed that can pop a "Where is Spotify?"
42
+ * picker, from a background hook. pgrep the process list first so we only
43
+ * ever compile scripts for players that are actually running. */
44
+ function isRunning(app) {
37
45
  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(""));
46
+ const child = execFile("pgrep", ["-qx", app], { timeout: 500 }, (err) => resolve(!err));
47
+ child.on("error", () => resolve(false));
48
+ });
49
+ }
50
+ /* execFile, not exec: the script must reach osascript byte-for-byte as one
51
+ * argv entry. Routing it through a shell means a quoting layer (where `\n`
52
+ * inside double quotes stays a literal backslash-n — instant -2740). */
53
+ export function osascript(script) {
54
+ return new Promise((resolve) => {
55
+ const child = execFile("osascript", ["-e", script], { timeout: 800 }, (err, stdout, stderr) => {
56
+ if (err)
57
+ debug("music", `osascript failed: ${stderr.trim() || err.message}`);
58
+ resolve(err ? "" : stdout.trim());
59
+ });
60
+ child.on("error", (e) => {
61
+ debug("music", `osascript spawn failed: ${e.message}`);
62
+ resolve("");
63
+ });
40
64
  });
41
65
  }
42
66
  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 ?? "" };
67
+ for (const player of PLAYERS) {
68
+ if (!(await isRunning(player))) {
69
+ debug("music", `${player} not running`);
70
+ continue;
71
+ }
72
+ const out = await osascript(playerScript(player));
73
+ if (!out)
74
+ continue; // running but paused/stopped (or script error, logged above)
75
+ const [track, artist] = out.split("|||");
76
+ if (!track || !artist)
77
+ continue;
78
+ return { track, artist, player };
79
+ }
80
+ return null;
50
81
  }
51
82
  async function loadCache() {
52
83
  try {
@@ -92,7 +123,8 @@ async function fetchTags(artist) {
92
123
  .slice(0, MAX_TAGS);
93
124
  return cleaned.length ? cleaned : null;
94
125
  }
95
- catch {
126
+ catch (e) {
127
+ debug("music", `musicbrainz lookup failed for "${artist}": ${e instanceof Error ? e.message : String(e)}`);
96
128
  return null;
97
129
  }
98
130
  finally {
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { getMusicSignal } from "./providers/music.js";
3
+ import { getSelfReportSignal } from "./providers/selfreport.js";
4
+ import { loadOverrides } from "./cadence.js";
5
+ import { debug } from "./debug.js";
6
+ /* ─────────────────────────────────────────────────────────────────────────
7
+ * Claude Code SessionStart adapter: one short, human-facing line when a
8
+ * session opens — is Cadence live, what does it currently see, and where
9
+ * do you input state. Discoverability, not context: the per-prompt
10
+ * UserPromptSubmit hook owns what the MODEL sees; this line is for YOU
11
+ * (delivered via `systemMessage`, which Claude Code shows to the user).
12
+ *
13
+ * Fires on "startup" only (see hooks/hooks.json matcher) — not on resume
14
+ * or clear — so it reads as a greeting, not a nag.
15
+ * ───────────────────────────────────────────────────────────────────────── */
16
+ const BUDGET_MS = 700;
17
+ // The voice of the product's first impression. Return null to stay silent.
18
+ // 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
+ export function composeHint(info) {
21
+ if (info.firstRun) {
22
+ return 'cadence: on, but it hasn\'t heard from you — try `cadence start` (or just `cadence state "deep work"`)';
23
+ }
24
+ const seen = [];
25
+ if (info.selfReport)
26
+ seen.push(`state "${info.selfReport}"`);
27
+ if (info.nowPlaying)
28
+ seen.push(`${info.nowPlaying.player}: ${info.nowPlaying.artist}`);
29
+ if (info.pinned.length)
30
+ seen.push(`pinned ${info.pinned.join(", ")}`);
31
+ if (seen.length === 0) {
32
+ return 'cadence: live, no signals right now — `cadence state "..."` to give it one';
33
+ }
34
+ return `cadence: live — ${seen.join(" · ")} (inputs: cadence state | dials)`;
35
+ }
36
+ async function collectInfo() {
37
+ // Race music against the budget — MusicBrainz on a brand-new artist can
38
+ // be slow, and a session greeting must never delay the session.
39
+ const [report, overrides, music] = await Promise.all([
40
+ getSelfReportSignal().catch(() => null),
41
+ loadOverrides(),
42
+ Promise.race([
43
+ getMusicSignal().catch(() => null),
44
+ new Promise((resolve) => setTimeout(() => resolve(null), BUDGET_MS)),
45
+ ]),
46
+ ]);
47
+ const pinned = Object.keys(overrides);
48
+ return {
49
+ selfReport: report?.text ?? null,
50
+ pinned,
51
+ nowPlaying: music?.artist ? { artist: music.artist, player: music.player ?? "music" } : null,
52
+ firstRun: !report && pinned.length === 0,
53
+ };
54
+ }
55
+ async function main() {
56
+ const hint = composeHint(await collectInfo());
57
+ if (!hint)
58
+ process.exit(0); // same contract as the prompt hook: silent when empty
59
+ process.stdout.write(JSON.stringify({ systemMessage: hint }));
60
+ }
61
+ main().catch((err) => {
62
+ debug("session-start", err instanceof Error ? err.message : String(err));
63
+ process.exit(0); // greeting must never break a session
64
+ });
package/hooks/hooks.json CHANGED
@@ -1,5 +1,16 @@
1
1
  {
2
2
  "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/session-start.js\""
10
+ }
11
+ ]
12
+ }
13
+ ],
3
14
  "UserPromptSubmit": [
4
15
  {
5
16
  "matcher": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cullumco/cadence",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Agents that read the room. Ambient context, cadence dials, and finish-line guardrails for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {