@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli.js +121 -6
- package/dist/debug.js +17 -0
- package/dist/hook.js +5 -1
- package/dist/providers/music.js +59 -27
- package/dist/session-start.js +64 -0
- package/hooks/hooks.json +11 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
]);
|
package/dist/providers/music.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if
|
|
34
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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